@aion0/forge 0.10.64 → 0.10.66
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 +6 -3
- package/app/chat/page.tsx +67 -6
- package/components/SettingsModal.tsx +8 -1
- package/lib/auth/idp-login.ts +5 -0
- package/lib/auth/login-status.ts +1 -1
- package/lib/chat/tool-dispatcher.ts +176 -8
- package/lib/connectors/sync.ts +6 -1
- package/lib/connectors/test-runner.ts +18 -3
- package/lib/help-docs/13-schedules.md +15 -3
- package/package.json +1 -1
- package/publish.sh +23 -6
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.66
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.65
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- fix(login-status): IdP probe honors auth_required_selector — no more same-host login-page false positives
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.65...v0.10.66
|
package/app/chat/page.tsx
CHANGED
|
@@ -206,12 +206,42 @@ export default function ChatPage() {
|
|
|
206
206
|
};
|
|
207
207
|
}, [activeId, loadMessages, refreshSessions]);
|
|
208
208
|
|
|
209
|
-
// ─── Auto-scroll on new content
|
|
209
|
+
// ─── Auto-scroll on new content — only when already at the bottom ──
|
|
210
|
+
// stickRef tracks whether the user is parked near the bottom. If they've
|
|
211
|
+
// scrolled up to read earlier turns, a refresh / new streamed chunk must
|
|
212
|
+
// NOT yank them back down. Updated on every scroll (see onScroll below).
|
|
213
|
+
const stickRef = useRef(true);
|
|
210
214
|
useEffect(() => {
|
|
211
215
|
const el = scrollRef.current;
|
|
212
|
-
if (el) el.scrollTop = el.scrollHeight;
|
|
216
|
+
if (el && stickRef.current) el.scrollTop = el.scrollHeight;
|
|
213
217
|
}, [messages, partial]);
|
|
214
218
|
|
|
219
|
+
// ─── Jump markers: one tick per user turn along the scroll track ──
|
|
220
|
+
const [markers, setMarkers] = useState<{ mid: string; pct: number; offset: number; label: string }[]>([]);
|
|
221
|
+
const recomputeMarkers = useCallback(() => {
|
|
222
|
+
const el = scrollRef.current;
|
|
223
|
+
if (!el) return;
|
|
224
|
+
const h = el.scrollHeight || 1;
|
|
225
|
+
const nodes = el.querySelectorAll<HTMLElement>('[data-role="user"][data-mid]');
|
|
226
|
+
setMarkers([...nodes].map((n) => ({
|
|
227
|
+
mid: n.dataset.mid || '',
|
|
228
|
+
offset: n.offsetTop,
|
|
229
|
+
pct: (n.offsetTop / h) * 100,
|
|
230
|
+
label: (n.dataset.label || '').slice(0, 80),
|
|
231
|
+
})));
|
|
232
|
+
}, []);
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
const id = requestAnimationFrame(recomputeMarkers);
|
|
235
|
+
return () => cancelAnimationFrame(id);
|
|
236
|
+
}, [messages, partial, recomputeMarkers]);
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
const el = scrollRef.current;
|
|
239
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
240
|
+
const ro = new ResizeObserver(() => recomputeMarkers());
|
|
241
|
+
ro.observe(el);
|
|
242
|
+
return () => ro.disconnect();
|
|
243
|
+
}, [recomputeMarkers]);
|
|
244
|
+
|
|
215
245
|
// ─── Prune stale watch chips (no update in >150s = done/gone) ──
|
|
216
246
|
useEffect(() => {
|
|
217
247
|
const t = setInterval(() => {
|
|
@@ -643,7 +673,15 @@ export default function ChatPage() {
|
|
|
643
673
|
</button>
|
|
644
674
|
</header>
|
|
645
675
|
|
|
646
|
-
<div
|
|
676
|
+
<div className="flex-1 relative min-h-0">
|
|
677
|
+
<div
|
|
678
|
+
ref={scrollRef}
|
|
679
|
+
className="absolute inset-0 overflow-y-auto"
|
|
680
|
+
onScroll={(e) => {
|
|
681
|
+
const el = e.currentTarget;
|
|
682
|
+
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
683
|
+
}}
|
|
684
|
+
>
|
|
647
685
|
<div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
|
|
648
686
|
{messages.length === 0 && !partial && !streaming && (
|
|
649
687
|
<div className="text-center text-sm text-[var(--text-secondary)] mt-12">
|
|
@@ -672,6 +710,25 @@ export default function ChatPage() {
|
|
|
672
710
|
)}
|
|
673
711
|
</div>
|
|
674
712
|
</div>
|
|
713
|
+
{/* Jump rail — one tick per user turn, mapped to its position in the
|
|
714
|
+
scroll content. Click to jump. Hidden until there are ≥2 turns. */}
|
|
715
|
+
{markers.length >= 2 && (
|
|
716
|
+
<div className="absolute right-0.5 top-2 bottom-2 w-3 z-10 pointer-events-none">
|
|
717
|
+
{markers.map((mk) => (
|
|
718
|
+
<button
|
|
719
|
+
key={mk.mid}
|
|
720
|
+
title={mk.label || 'jump to message'}
|
|
721
|
+
onClick={() => {
|
|
722
|
+
const el = scrollRef.current;
|
|
723
|
+
if (el) el.scrollTo({ top: Math.max(0, mk.offset - 12), behavior: 'smooth' });
|
|
724
|
+
}}
|
|
725
|
+
className="pointer-events-auto absolute right-0 -translate-y-1/2 h-1.5 w-1.5 rounded-full bg-[var(--text-secondary)] opacity-40 hover:opacity-100 hover:w-2.5 hover:bg-[var(--accent)] transition-all"
|
|
726
|
+
style={{ top: `${Math.min(99, Math.max(1, mk.pct))}%` }}
|
|
727
|
+
/>
|
|
728
|
+
))}
|
|
729
|
+
</div>
|
|
730
|
+
)}
|
|
731
|
+
</div>
|
|
675
732
|
|
|
676
733
|
{Object.keys(watchChips).length > 0 && (
|
|
677
734
|
<div className="px-6 pt-2">
|
|
@@ -775,7 +832,7 @@ function fmtTs(ts?: number): string {
|
|
|
775
832
|
return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
|
|
776
833
|
}
|
|
777
834
|
|
|
778
|
-
function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; children: React.ReactNode }) {
|
|
835
|
+
function RoleBlock({ role, ts, pending, mid, label, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; mid?: string; label?: string; children: React.ReactNode }) {
|
|
779
836
|
const isUser = role === 'user';
|
|
780
837
|
// User → right-aligned with bubble; assistant → left-aligned (avatar +
|
|
781
838
|
// expanded content for markdown / tool cards). Pending optimistic notes
|
|
@@ -783,7 +840,7 @@ function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'
|
|
|
783
840
|
// replaces them on the loop's next iteration.
|
|
784
841
|
if (isUser) {
|
|
785
842
|
return (
|
|
786
|
-
<div className="flex justify-end">
|
|
843
|
+
<div className="flex justify-end" data-role="user" data-mid={mid} data-label={label}>
|
|
787
844
|
<div className="max-w-[80%]">
|
|
788
845
|
<div className="flex items-baseline gap-2 mb-1 justify-end">
|
|
789
846
|
{pending ? (
|
|
@@ -834,8 +891,12 @@ function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'
|
|
|
834
891
|
// chat became visibly laggy.
|
|
835
892
|
const MessageView = memo(function MessageView({ m }: { m: Message }) {
|
|
836
893
|
const pending = typeof m.id === 'string' && m.id.startsWith('optimistic-note-');
|
|
894
|
+
// Label for the jump rail = first text block of a user turn, trimmed.
|
|
895
|
+
const label = m.role === 'user'
|
|
896
|
+
? (m.blocks.find((b) => b.type === 'text')?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
897
|
+
: undefined;
|
|
837
898
|
return (
|
|
838
|
-
<RoleBlock role={m.role} ts={m.ts} pending={pending}>
|
|
899
|
+
<RoleBlock role={m.role} ts={m.ts} pending={pending} mid={String(m.id)} label={label}>
|
|
839
900
|
{m.blocks.map((b, i) => (
|
|
840
901
|
<BlockView key={i} b={b} role={m.role} />
|
|
841
902
|
))}
|
|
@@ -1799,7 +1799,7 @@ function MarketplaceProvidersSection() {
|
|
|
1799
1799
|
}
|
|
1800
1800
|
|
|
1801
1801
|
function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1802
|
-
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1802
|
+
const { registry: modelsRegistry, refresh: refreshModels, loading: modelsLoading } = useModelsRegistry();
|
|
1803
1803
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
1804
1804
|
const [loading, setLoading] = useState(true);
|
|
1805
1805
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
@@ -2126,6 +2126,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
2126
2126
|
{/* Preset models */}
|
|
2127
2127
|
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
|
2128
2128
|
<span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
|
|
2129
|
+
<button
|
|
2130
|
+
type="button"
|
|
2131
|
+
onClick={() => { void refreshModels(); }}
|
|
2132
|
+
disabled={modelsLoading}
|
|
2133
|
+
title="Refresh model list from the public-info registry (bypasses the 24h cache)"
|
|
2134
|
+
className="text-[8px] px-1 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
2135
|
+
>{modelsLoading ? '…' : '↻'}</button>
|
|
2129
2136
|
{((() => {
|
|
2130
2137
|
const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
|
|
2131
2138
|
// Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
|
package/lib/auth/idp-login.ts
CHANGED
|
@@ -56,6 +56,11 @@ 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
|
+
/** DOM selector present ONLY on the login page (e.g. 'input[type=password]').
|
|
60
|
+
* Required for IdPs that serve their login form on the SAME host as the
|
|
61
|
+
* logged-in page (sso.frval.fortinet-emea.com) — without it the host-only
|
|
62
|
+
* probe falsely reports "signed in" when a login screen is showing. */
|
|
63
|
+
auth_required_selector?: string;
|
|
59
64
|
/** Optional alt hostnames the IdP also uses (e.g. regional SAML servers). */
|
|
60
65
|
alt_hosts?: string[];
|
|
61
66
|
saml_sps?: string[];
|
package/lib/auth/login-status.ts
CHANGED
|
@@ -235,7 +235,7 @@ async function checkIdp(
|
|
|
235
235
|
const block = readIdpBlocks().find((b) => b.host === host);
|
|
236
236
|
const probe_url = block ? idpProbeUrl(block) : `https://${host}/`;
|
|
237
237
|
try {
|
|
238
|
-
const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url });
|
|
238
|
+
const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url, auth_required_selector: block?.auth_required_selector });
|
|
239
239
|
return {
|
|
240
240
|
ok: !!r.ok,
|
|
241
241
|
message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
|
|
@@ -116,6 +116,53 @@ async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Download channel (mirror of the scratch:// upload path). A connector that
|
|
120
|
+
// fetched a binary (e.g. owa.download_attachment) can't return the bytes
|
|
121
|
+
// through the model — the 16KB tool-result cap would shred any base64. So it
|
|
122
|
+
// returns `_files: [{ filename, content_base64, content_type }]` and we
|
|
123
|
+
// materialize them HERE: decode → write to <dataDir>/tmp/ → strip the base64
|
|
124
|
+
// and replace each entry with a small {filename, path, file_url, size_bytes}
|
|
125
|
+
// pointer the model can hand to read_forge_file / extract_archive. Bytes
|
|
126
|
+
// never reach the LLM. Generic — any protocol's result is scanned.
|
|
127
|
+
async function materializeConnectorFiles(result: ToolResult): Promise<ToolResult> {
|
|
128
|
+
let parsed: unknown;
|
|
129
|
+
try { parsed = JSON.parse(result.content); } catch { return result; }
|
|
130
|
+
if (!parsed || typeof parsed !== 'object') return result;
|
|
131
|
+
const files = (parsed as { _files?: unknown })._files;
|
|
132
|
+
if (!Array.isArray(files) || files.length === 0) return result;
|
|
133
|
+
|
|
134
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
135
|
+
const { dirname, basename } = await import('node:path');
|
|
136
|
+
const out: Array<Record<string, unknown>> = [];
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const e = (f && typeof f === 'object') ? f as Record<string, unknown> : {};
|
|
139
|
+
const b64 = typeof e.content_base64 === 'string' ? e.content_base64 : '';
|
|
140
|
+
const rawName = String(e.filename || e.name || 'download.bin');
|
|
141
|
+
// Bare, sanitized name under tmp/ — no traversal, no nesting (the cache
|
|
142
|
+
// janitor only sweeps top-level tmp/ files).
|
|
143
|
+
const safe = (basename(rawName).replace(/[\0/\\]/g, '_').replace(/^\.+/, '') || 'download.bin').slice(0, 200);
|
|
144
|
+
if (!b64) { out.push({ ...e, error: 'no content_base64' }); continue; }
|
|
145
|
+
try {
|
|
146
|
+
const buf = Buffer.from(b64, 'base64');
|
|
147
|
+
const target = await resolveTmpPath(safe);
|
|
148
|
+
await mkdir(dirname(target), { recursive: true });
|
|
149
|
+
await writeFile(target, buf);
|
|
150
|
+
out.push({
|
|
151
|
+
filename: safe,
|
|
152
|
+
path: `tmp/${safe}`,
|
|
153
|
+
local_path: target,
|
|
154
|
+
file_url: `file://${target}`,
|
|
155
|
+
size_bytes: buf.length,
|
|
156
|
+
...(e.content_type ? { content_type: e.content_type } : {}),
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
out.push({ filename: safe, error: (err as Error).message });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const next = { ...(parsed as Record<string, unknown>), _files: out };
|
|
163
|
+
return { ...result, content: JSON.stringify(next) };
|
|
164
|
+
}
|
|
165
|
+
|
|
119
166
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
120
167
|
get_current_time: async () => new Date().toISOString(),
|
|
121
168
|
|
|
@@ -475,6 +522,82 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
475
522
|
});
|
|
476
523
|
},
|
|
477
524
|
|
|
525
|
+
// Extract a zip/tar/gz archive sitting in tmp/ (e.g. one a connector just
|
|
526
|
+
// downloaded via the _files channel) into tmp/<base>-extracted/, then
|
|
527
|
+
// return the file listing so the agent can read_forge_file each entry.
|
|
528
|
+
// The chat agent has no shell, so this shells out to system unzip/tar/
|
|
529
|
+
// gunzip on its behalf. Stays inside tmp/ on both ends.
|
|
530
|
+
extract_archive: async (input) => {
|
|
531
|
+
const params = (input as { filename?: string } | undefined) || {};
|
|
532
|
+
const raw = String(params.filename || '').trim().replace(/^tmp\//, '');
|
|
533
|
+
if (!raw) return JSON.stringify({ ok: false, error: 'filename is required (a tmp/ archive, e.g. "report.zip")' });
|
|
534
|
+
let archive: string;
|
|
535
|
+
try { archive = await resolveTmpPath(raw); }
|
|
536
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
537
|
+
const { existsSync } = await import('node:fs');
|
|
538
|
+
if (!existsSync(archive)) return JSON.stringify({ ok: false, error: `archive not found: tmp/${raw}` });
|
|
539
|
+
|
|
540
|
+
const lower = raw.toLowerCase();
|
|
541
|
+
const base = raw.replace(/\.(zip|tar\.gz|tgz|tar|gz)$/i, '');
|
|
542
|
+
const outRel = `${base}-extracted`;
|
|
543
|
+
let outDir: string;
|
|
544
|
+
try { outDir = await resolveTmpPath(outRel); }
|
|
545
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
546
|
+
|
|
547
|
+
const { execFile } = await import('node:child_process');
|
|
548
|
+
const { promisify } = await import('node:util');
|
|
549
|
+
const { mkdir, readdir, stat } = await import('node:fs/promises');
|
|
550
|
+
const { join, relative } = await import('node:path');
|
|
551
|
+
const run = promisify(execFile);
|
|
552
|
+
await mkdir(outDir, { recursive: true });
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
if (lower.endsWith('.zip')) {
|
|
556
|
+
await run('unzip', ['-o', '-qq', archive, '-d', outDir], { timeout: 60000 });
|
|
557
|
+
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
|
|
558
|
+
await run('tar', ['-xf', archive, '-C', outDir], { timeout: 60000 });
|
|
559
|
+
} else if (lower.endsWith('.gz')) {
|
|
560
|
+
// single-file gzip → decompress to the same basename inside outDir
|
|
561
|
+
const { createReadStream, createWriteStream } = await import('node:fs');
|
|
562
|
+
const { createGunzip } = await import('node:zlib');
|
|
563
|
+
const { pipeline } = await import('node:stream/promises');
|
|
564
|
+
const dest = join(outDir, base.split('/').pop() || 'file');
|
|
565
|
+
await pipeline(createReadStream(archive), createGunzip(), createWriteStream(dest));
|
|
566
|
+
} else {
|
|
567
|
+
return JSON.stringify({ ok: false, error: `unsupported archive type: ${raw} (supported: .zip .tar .tar.gz .tgz .gz)` });
|
|
568
|
+
}
|
|
569
|
+
} catch (e) {
|
|
570
|
+
return JSON.stringify({ ok: false, error: `extraction failed: ${(e as Error).message}` });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Walk the extracted tree (capped) and return tmp-relative paths.
|
|
574
|
+
const files: Array<{ path: string; size_bytes: number }> = [];
|
|
575
|
+
const { getDataDir } = await import('../dirs');
|
|
576
|
+
const tmpRoot = join(getDataDir(), 'tmp');
|
|
577
|
+
async function walk(dir: string, depth: number): Promise<void> {
|
|
578
|
+
if (files.length >= 500 || depth > 8) return;
|
|
579
|
+
for (const ent of await readdir(dir, { withFileTypes: true })) {
|
|
580
|
+
if (files.length >= 500) break;
|
|
581
|
+
const full = join(dir, ent.name);
|
|
582
|
+
if (ent.isDirectory()) { await walk(full, depth + 1); continue; }
|
|
583
|
+
if (ent.isFile()) {
|
|
584
|
+
const s = await stat(full);
|
|
585
|
+
files.push({ path: `tmp/${relative(tmpRoot, full)}`, size_bytes: s.size });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
try { await walk(outDir, 0); } catch { /* partial listing is fine */ }
|
|
590
|
+
|
|
591
|
+
return JSON.stringify({
|
|
592
|
+
ok: true,
|
|
593
|
+
archive: `tmp/${raw}`,
|
|
594
|
+
extracted_dir: `tmp/${outRel}`,
|
|
595
|
+
count: files.length,
|
|
596
|
+
files,
|
|
597
|
+
note: 'Read any entry with read_forge_file (pass its `path`). Extracted dir is NOT auto-swept by the cache janitor (it only sweeps top-level tmp/ files).',
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
|
|
478
601
|
// List files anywhere under <dataDir>/ (Forge's own data dir).
|
|
479
602
|
// Replaces the "dispatch a task just to `ls`" workaround. The `dir`
|
|
480
603
|
// param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
|
|
@@ -626,8 +749,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
626
749
|
const p = (input as any) || {};
|
|
627
750
|
const name = String(p.name || '').trim();
|
|
628
751
|
const workflow = String(p.workflow || p.body_ref || '').trim();
|
|
752
|
+
const promptText = String(p.prompt || '').trim();
|
|
629
753
|
if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
|
|
630
|
-
if (!workflow
|
|
754
|
+
if (!workflow && !promptText) {
|
|
755
|
+
return JSON.stringify({ ok: false, error: 'provide either prompt (free-text instructions — dispatched as a one-shot Claude task WITH connector tools) or workflow (an existing pipeline name)' });
|
|
756
|
+
}
|
|
631
757
|
|
|
632
758
|
// Trigger normalization: prefer every_minutes; accept at (once) or cron.
|
|
633
759
|
let schedule_kind: 'period' | 'once' | 'cron' = 'period';
|
|
@@ -647,14 +773,36 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
647
773
|
return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
|
|
648
774
|
}
|
|
649
775
|
|
|
776
|
+
// Body resolution. prompt mode (preferred for "run these instructions on
|
|
777
|
+
// a schedule") wins when both are somehow given — it dispatches a one-shot
|
|
778
|
+
// Claude task that HAS the full connector toolset (Teams/Mantis/etc.), so
|
|
779
|
+
// no pipeline YAML is needed. The prompt text is persisted to
|
|
780
|
+
// prompts/<slug>.yaml and the schedule row just references it by name.
|
|
781
|
+
let body_kind: 'pipeline' | 'prompt' = 'pipeline';
|
|
782
|
+
let body_ref = workflow;
|
|
783
|
+
const skills = Array.isArray(p.skills) ? p.skills : undefined;
|
|
784
|
+
if (promptText) {
|
|
785
|
+
const slug = (name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'schedule').slice(0, 64);
|
|
786
|
+
try {
|
|
787
|
+
const { createPrompt, getPrompt, updatePrompt } = await import('../prompts/store');
|
|
788
|
+
const execu = skills ? { skills } : undefined;
|
|
789
|
+
if (getPrompt(slug)) updatePrompt(slug, { prompt: promptText, executor: execu });
|
|
790
|
+
else createPrompt({ name: slug, prompt: promptText, executor: execu });
|
|
791
|
+
} catch (e) {
|
|
792
|
+
return JSON.stringify({ ok: false, error: `failed to save prompt: ${(e as Error).message}` });
|
|
793
|
+
}
|
|
794
|
+
body_kind = 'prompt';
|
|
795
|
+
body_ref = slug;
|
|
796
|
+
}
|
|
797
|
+
|
|
650
798
|
const { createSchedule, seedNextRunAt } = await import('../schedules/store');
|
|
651
799
|
try {
|
|
652
800
|
const s = createSchedule({
|
|
653
801
|
name,
|
|
654
|
-
body_kind
|
|
655
|
-
body_ref
|
|
802
|
+
body_kind,
|
|
803
|
+
body_ref,
|
|
656
804
|
input: (p.input && typeof p.input === 'object') ? p.input : {},
|
|
657
|
-
skills
|
|
805
|
+
skills,
|
|
658
806
|
enabled: p.enabled !== false,
|
|
659
807
|
schedule_kind,
|
|
660
808
|
schedule_interval_minutes,
|
|
@@ -889,6 +1037,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
889
1037
|
required: ['filename'],
|
|
890
1038
|
},
|
|
891
1039
|
},
|
|
1040
|
+
{
|
|
1041
|
+
name: 'extract_archive',
|
|
1042
|
+
description: 'Unpack an archive sitting in tmp/ (e.g. one a connector just downloaded — owa.download_attachment etc.) into tmp/<base>-extracted/, and return the file listing. USE THIS when an attachment / download is a .zip / .tar / .tar.gz / .tgz / .gz and you need to read what is inside — you have no shell, this runs unzip/tar for you. Then read individual entries with read_forge_file using each returned `path`. Returns JSON {ok, extracted_dir, count, files:[{path,size_bytes}]}.',
|
|
1043
|
+
input_schema: {
|
|
1044
|
+
type: 'object',
|
|
1045
|
+
properties: {
|
|
1046
|
+
filename: { type: 'string', description: 'tmp/ archive to extract, e.g. "report.zip" or "tmp/logs.tar.gz". Must already be in tmp/ (a download lands there via the connector _files channel).' },
|
|
1047
|
+
},
|
|
1048
|
+
required: ['filename'],
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
892
1051
|
{
|
|
893
1052
|
name: 'list_forge_files',
|
|
894
1053
|
description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
|
|
@@ -947,12 +1106,13 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
947
1106
|
},
|
|
948
1107
|
{
|
|
949
1108
|
name: 'create_schedule',
|
|
950
|
-
description: 'Create a recurring (or one-off) schedule
|
|
1109
|
+
description: 'Create a recurring (or one-off) schedule on a timer. NO HTTP, NO auth — runs in-process. Use when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run on schedule". TWO body modes — pick ONE:\n• prompt mode (PREFERRED for "do these instructions on a schedule"): pass `prompt` with free-text instructions. Each fire dispatches a one-shot Claude task that HAS THE FULL CONNECTOR TOOLSET (Teams/Mantis/GitLab/etc.) and your skills. No pipeline YAML needed — do NOT build a throwaway pipeline just to run a prompt on a timer.\n• pipeline mode: pass `workflow` (an existing pipeline name) for a structured multi-node DAG.\nREQUIRED: name + (prompt OR workflow) + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
|
|
951
1110
|
input_schema: {
|
|
952
1111
|
type: 'object',
|
|
953
1112
|
properties: {
|
|
954
|
-
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
|
|
955
|
-
|
|
1113
|
+
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI. Also used as the prompt slug in prompt mode.' },
|
|
1114
|
+
prompt: { type: 'string', description: 'PROMPT MODE: free-text instructions run as a one-shot Claude task on each fire, with full connector tools + skills. Use this for "every hour check Teams channel X and notify me" style asks. Mutually exclusive with workflow.' },
|
|
1115
|
+
workflow: { type: 'string', description: 'PIPELINE MODE: existing pipeline workflow name (basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first to list. Mutually exclusive with prompt.' },
|
|
956
1116
|
input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
|
|
957
1117
|
skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
|
|
958
1118
|
every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
|
|
@@ -961,7 +1121,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
961
1121
|
enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
|
|
962
1122
|
action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
|
|
963
1123
|
},
|
|
964
|
-
required: ['name'
|
|
1124
|
+
required: ['name'],
|
|
965
1125
|
},
|
|
966
1126
|
},
|
|
967
1127
|
{
|
|
@@ -1374,6 +1534,14 @@ export async function dispatchTool(
|
|
|
1374
1534
|
return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
|
|
1375
1535
|
}
|
|
1376
1536
|
|
|
1537
|
+
// Download channel: materialize any `_files` base64 payload to tmp/ and
|
|
1538
|
+
// strip the bytes before they reach the model (and before the watch
|
|
1539
|
+
// below re-parses the result). No-op when the result has no _files.
|
|
1540
|
+
if (!result.is_error) {
|
|
1541
|
+
try { result = await materializeConnectorFiles(result); }
|
|
1542
|
+
catch (e) { console.warn('[dispatch] materializeConnectorFiles failed', (e as Error).message); }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1377
1545
|
// Async (long-task watch): if the tool declared an `async` block and
|
|
1378
1546
|
// it ran without error, register a background watch that polls to
|
|
1379
1547
|
// completion and reports back to the originating chat session. The
|
package/lib/connectors/sync.ts
CHANGED
|
@@ -335,11 +335,16 @@ export async function fetchSourceFile(source: SourceMeta, relPath: string): Prom
|
|
|
335
335
|
// directly (no base64 decode needed). Works for private repos given
|
|
336
336
|
// a Fine-grained PAT with Contents: read.
|
|
337
337
|
const repo = (source.repo_url || '').replace(/^github\.com\//, '');
|
|
338
|
-
|
|
338
|
+
// &_t + no-cache: the GitHub contents API edge-caches the ?ref=main blob,
|
|
339
|
+
// so right after a push a plain fetch can return the STALE file — which
|
|
340
|
+
// made the marketplace keep showing old connector versions even after
|
|
341
|
+
// "↻ Sync all". The raw mode already busts via cacheBust(); match it here.
|
|
342
|
+
const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main&_t=${Date.now()}`;
|
|
339
343
|
try {
|
|
340
344
|
return await rawFetch(url, {
|
|
341
345
|
Authorization: `Bearer ${source.github_pat}`,
|
|
342
346
|
Accept: 'application/vnd.github.raw',
|
|
347
|
+
'Cache-Control': 'no-cache',
|
|
343
348
|
});
|
|
344
349
|
} catch (err) {
|
|
345
350
|
// GitHub returns 404 (not 401) for private repos when the PAT
|
|
@@ -283,6 +283,12 @@ export async function runBrowserUrlProbe(opts: {
|
|
|
283
283
|
id: string; // identifier for telemetry, e.g. 'idp:fac.corp.fortinet.com'
|
|
284
284
|
host: string; // expected host (e.g. 'fac.corp.fortinet.com')
|
|
285
285
|
probe_url: string; // URL to navigate to (e.g. 'https://fac.corp.fortinet.com/saml-idp/portal/')
|
|
286
|
+
// DOM selector present ONLY on the login page (e.g. 'input[type=password]').
|
|
287
|
+
// Required for IdPs that serve their login form on the SAME host as the
|
|
288
|
+
// logged-in page (sso.frval.fortinet-emea.com), where the host-only check
|
|
289
|
+
// can't tell logged-in from a login screen. Extension v0.2.15+ runs the
|
|
290
|
+
// querySelector and returns ok:false on a match.
|
|
291
|
+
auth_required_selector?: string;
|
|
286
292
|
timeout_ms?: number;
|
|
287
293
|
}): Promise<TestResult> {
|
|
288
294
|
const t0 = Date.now();
|
|
@@ -291,6 +297,7 @@ export async function runBrowserUrlProbe(opts: {
|
|
|
291
297
|
value = await bridgeRpc('connector.probe', {
|
|
292
298
|
pluginId: opts.id,
|
|
293
299
|
host_match: opts.probe_url, // extension navigates to this URL directly
|
|
300
|
+
auth_required_selector: opts.auth_required_selector || undefined,
|
|
294
301
|
runner: 'main',
|
|
295
302
|
timeout_ms: opts.timeout_ms || 15_000,
|
|
296
303
|
});
|
|
@@ -303,8 +310,14 @@ export async function runBrowserUrlProbe(opts: {
|
|
|
303
310
|
// Pass if landed host matches expected. A redirect to a different host
|
|
304
311
|
// (e.g. fac.corp.fortinet.com → ms-login.fortinet.com when SSO expired)
|
|
305
312
|
// means the user needs to re-authenticate.
|
|
306
|
-
let
|
|
307
|
-
try {
|
|
313
|
+
let hostnameMatch = false;
|
|
314
|
+
try { hostnameMatch = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
|
|
315
|
+
// When auth_required_selector is set, the extension flips ok:false if the
|
|
316
|
+
// login form is on the page — so a same-host login screen no longer passes.
|
|
317
|
+
// Older extensions / no selector → r.ok is undefined and we fall back to
|
|
318
|
+
// the host-only check.
|
|
319
|
+
const loginFormShown = opts.auth_required_selector ? r.ok === false : false;
|
|
320
|
+
const onExpected = hostnameMatch && !loginFormShown;
|
|
308
321
|
return {
|
|
309
322
|
ok: onExpected,
|
|
310
323
|
message: onExpected ? `Session active · ${landed}` : undefined,
|
|
@@ -312,7 +325,9 @@ export async function runBrowserUrlProbe(opts: {
|
|
|
312
325
|
? undefined
|
|
313
326
|
: (failedNetwork
|
|
314
327
|
? `Network unreachable — ${landed || '(no url)'}. VPN / hostname / firewall?`
|
|
315
|
-
:
|
|
328
|
+
: loginFormShown
|
|
329
|
+
? `Not signed in — login page shown at ${landed}`
|
|
330
|
+
: `Not signed in — redirected to ${landed}`),
|
|
316
331
|
url: landed,
|
|
317
332
|
duration_ms: Date.now() - t0,
|
|
318
333
|
};
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# Schedules
|
|
2
2
|
|
|
3
|
-
**Schedule = a
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**Schedule = a body + trigger.** The trigger decides *when* to fire; the body
|
|
4
|
+
decides *what* runs. There are two body kinds — pick whichever fits:
|
|
5
|
+
|
|
6
|
+
- **Prompt** (simplest, most common) — free-text instructions run as a one-shot
|
|
7
|
+
Claude task on each fire. **The task has the full connector toolset
|
|
8
|
+
(Teams / Mantis / GitLab / etc.) plus any skills you attach** — so "every hour
|
|
9
|
+
check the FortiNAC Teams channel and message me anything new" is a single
|
|
10
|
+
prompt schedule. You do **not** need a pipeline for this. The prompt text is
|
|
11
|
+
saved to `prompts/<slug>.yaml` and the schedule row references it by name.
|
|
12
|
+
- **Pipeline** — fires an existing pipeline (`flows/<name>.yaml`) with input.
|
|
13
|
+
Use when you need a structured multi-node DAG, `for_each:` iteration, or
|
|
14
|
+
per-node project/worktree control.
|
|
15
|
+
|
|
16
|
+
> Don't build a throwaway pipeline just to run a prompt on a timer — use prompt
|
|
17
|
+
> mode. The chat `create_schedule` tool accepts either `prompt` or `workflow`.
|
|
6
18
|
|
|
7
19
|
**Note:** Jobs (the older subsystem) is deprecated — its UI is hidden and the
|
|
8
20
|
scheduler no longer ticks. Any existing rows in the `jobs` table are inert.
|
package/package.json
CHANGED
package/publish.sh
CHANGED
|
@@ -9,11 +9,18 @@
|
|
|
9
9
|
|
|
10
10
|
set -e
|
|
11
11
|
|
|
12
|
-
# Preflight: fail BEFORE any version bump / commit / tag / push if gh
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# Preflight: fail BEFORE any version bump / commit / tag / push if gh can't
|
|
13
|
+
# actually talk to GitHub. We hit a real endpoint (gh api user) instead of
|
|
14
|
+
# `gh auth status` — status only checks a token is STORED, not that it still
|
|
15
|
+
# works; a stale/revoked keyring token passes status but 401s on every call.
|
|
16
|
+
GH_LOGIN_HINT="gh auth login --hostname github.com --web --git-protocol ssh"
|
|
15
17
|
if command -v gh &> /dev/null; then
|
|
16
|
-
gh
|
|
18
|
+
gh api user &> /dev/null || {
|
|
19
|
+
echo "✗ gh can't authenticate to GitHub (token missing/expired/revoked)."
|
|
20
|
+
echo " Re-auth, then re-run ./publish.sh:"
|
|
21
|
+
echo " $GH_LOGIN_HINT"
|
|
22
|
+
exit 1
|
|
23
|
+
}
|
|
17
24
|
fi
|
|
18
25
|
|
|
19
26
|
VERSION_ARG=${1:-patch}
|
|
@@ -144,8 +151,18 @@ git push origin "v$NEW_VERSION"
|
|
|
144
151
|
if command -v gh &> /dev/null; then
|
|
145
152
|
echo ""
|
|
146
153
|
echo "Creating GitHub Release..."
|
|
147
|
-
gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"
|
|
148
|
-
|
|
154
|
+
if gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"; then
|
|
155
|
+
echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
|
|
156
|
+
else
|
|
157
|
+
# The tag is already pushed at this point — re-auth and just create the
|
|
158
|
+
# release, no need to re-run the whole publish. A stale keyring token
|
|
159
|
+
# 401s here even though `gh auth status` claims you're logged in.
|
|
160
|
+
echo ""
|
|
161
|
+
echo "✗ GitHub Release failed (usually a stale gh token — 401). The tag v$NEW_VERSION is already pushed."
|
|
162
|
+
echo " Fix: re-auth, then create just the release:"
|
|
163
|
+
echo " $GH_LOGIN_HINT"
|
|
164
|
+
echo " gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-file $RELEASE_NOTES_FILE"
|
|
165
|
+
fi
|
|
149
166
|
else
|
|
150
167
|
echo ""
|
|
151
168
|
echo "gh CLI not found. Create release manually:"
|