@aion0/forge 0.10.53 → 0.10.56
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 +14 -3
- package/app/api/activity/summary/route.ts +30 -0
- package/app/api/cache/route.ts +125 -41
- package/app/api/chat/sessions/[id]/abort/route.ts +14 -0
- package/app/api/chat/sessions/[id]/note/route.ts +16 -0
- package/app/api/files/[...path]/route.ts +94 -0
- package/app/api/scratch/[...path]/route.ts +5 -0
- package/app/chat/page.tsx +237 -36
- package/app/files/[...path]/page.tsx +22 -0
- package/components/Dashboard.tsx +82 -26
- package/components/PipelineView.tsx +40 -7
- package/components/ScratchViewer.tsx +14 -3
- package/lib/chat/agent-loop.ts +95 -2
- package/lib/chat/input-queue.ts +159 -0
- package/lib/chat/link-patterns.ts +28 -5
- package/lib/chat/tool-dispatcher.ts +270 -17
- package/lib/chat/turn-control.ts +109 -0
- package/lib/chat-standalone.ts +75 -21
- package/lib/help-docs/10-troubleshooting.md +16 -0
- package/lib/help-docs/17-connectors.md +19 -0
- package/lib/help-docs/25-chat-tools.md +125 -0
- package/lib/help-docs/CLAUDE.md +2 -0
- package/lib/init.ts +14 -0
- package/lib/pipeline.ts +11 -0
- package/lib/scratch-cleanup.ts +25 -16
- package/lib/task-manager.ts +30 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.56
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-09
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.55
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- revert(pipeline): retryNode strict semantics — don't touch 'skipped' nodes
|
|
9
|
+
- fix(pipeline): retryNode walks upstream — auto-includes failed/skipped chain
|
|
10
|
+
- fix(tasks): reconcile orphaned 'running' rows at boot; pipeline retry accepts 'skipped'
|
|
11
|
+
- fix(chat): runTurn endTurn always runs — outer try/finally wraps full body
|
|
12
|
+
- fix(chat): atomic turn claim — close race where two inputs both fork runTurn
|
|
13
|
+
- feat(pipeline-ui): retry button on failed forEach iteration nodes
|
|
14
|
+
- refactor(chat): single input queue — all sources route through enqueueChatInput
|
|
15
|
+
- fix(chat): watch-triggered runChat merges into running turn, no concurrent fork
|
|
16
|
+
- feat(activity): running_tasks in summary API + taskId/pipeline deeplinks
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
|
|
19
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.55...v0.10.56
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { NextResponse } from 'next/server';
|
|
14
14
|
import { listPipelinesSummary } from '@/lib/pipeline';
|
|
15
15
|
import { listSchedules } from '@/lib/schedules/store';
|
|
16
|
+
import { listTasksLite } from '@/lib/task-manager';
|
|
16
17
|
|
|
17
18
|
interface RunningRow {
|
|
18
19
|
id: string;
|
|
@@ -41,8 +42,20 @@ interface RecentRow {
|
|
|
41
42
|
durationMs: number | null;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
interface RunningTaskRow {
|
|
46
|
+
id: string;
|
|
47
|
+
project: string;
|
|
48
|
+
prompt_preview: string;
|
|
49
|
+
status: string;
|
|
50
|
+
startedAt: string | null;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
agent: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
interface Summary {
|
|
45
56
|
running: RunningRow[];
|
|
57
|
+
/** Currently dispatched Claude CLI tasks (separate from pipelines). */
|
|
58
|
+
running_tasks: RunningTaskRow[];
|
|
46
59
|
upcoming: UpcomingRow[];
|
|
47
60
|
recent: RecentRow[];
|
|
48
61
|
generated_at: string;
|
|
@@ -125,8 +138,25 @@ export async function GET() {
|
|
|
125
138
|
schedule_summary: scheduleSummary(s),
|
|
126
139
|
}));
|
|
127
140
|
|
|
141
|
+
// Running tasks — dispatched via chat's dispatch_task or pipeline node.
|
|
142
|
+
// Lite list (no log / git_diff / result_summary) — same shape constraint
|
|
143
|
+
// as listPipelinesSummary; we just need name + status + project for the
|
|
144
|
+
// activity bar. Top 20 by recency to bound the response.
|
|
145
|
+
const runningTasks: RunningTaskRow[] = listTasksLite('running')
|
|
146
|
+
.slice(0, 20)
|
|
147
|
+
.map((t) => ({
|
|
148
|
+
id: t.id,
|
|
149
|
+
project: t.projectName,
|
|
150
|
+
prompt_preview: (t.prompt || '').replace(/\s+/g, ' ').slice(0, 80),
|
|
151
|
+
status: t.status,
|
|
152
|
+
startedAt: t.startedAt ?? null,
|
|
153
|
+
createdAt: t.createdAt,
|
|
154
|
+
agent: t.agent || null,
|
|
155
|
+
}));
|
|
156
|
+
|
|
128
157
|
const summary: Summary = {
|
|
129
158
|
running,
|
|
159
|
+
running_tasks: runningTasks,
|
|
130
160
|
upcoming,
|
|
131
161
|
recent,
|
|
132
162
|
generated_at: new Date().toISOString(),
|
package/app/api/cache/route.ts
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GET /api/cache →
|
|
3
|
-
* DELETE /api/cache → wipe
|
|
4
|
-
* DELETE /api/cache?name=<entry> → wipe a single entry.
|
|
2
|
+
* GET /api/cache → cache contents + total size.
|
|
3
|
+
* DELETE /api/cache → wipe everything cacheable.
|
|
4
|
+
* DELETE /api/cache?name=<entry>&category=<cat> → wipe a single entry.
|
|
5
5
|
*
|
|
6
|
-
* Backs the "Cache" item in the user dropdown.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Backs the "Cache" item in the user dropdown. Three categories:
|
|
7
|
+
* • cloned-projects — git clones Forge made for pipeline runs
|
|
8
|
+
* • tmp — chat-saved files (save_tmp_file, new default)
|
|
9
|
+
* • scratch — top-level files in <dataDir>/scratch/. Includes
|
|
10
|
+
* legacy save_scratch_file output (pre-rename) and
|
|
11
|
+
* anything else dropped at scratch root. Subdirs
|
|
12
|
+
* (task workdirs) are NOT listed — they belong to
|
|
13
|
+
* live workspaces, not cache. CLAUDE.md (scratch-
|
|
14
|
+
* project marker) is skipped.
|
|
15
|
+
*
|
|
16
|
+
* The `entries` array is flat (categories mixed) so existing Dashboard
|
|
17
|
+
* UI keeps working without changes — each entry carries a `category`
|
|
18
|
+
* field for display / per-category deletion. Janitor scope matches:
|
|
19
|
+
* lib/scratch-cleanup.ts sweeps tmp/ + scratch/ top-level files.
|
|
9
20
|
*/
|
|
10
21
|
|
|
11
22
|
import { NextResponse } from 'next/server';
|
|
12
|
-
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
23
|
+
import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
|
|
13
24
|
import { join } from 'node:path';
|
|
14
25
|
import { getDataDir } from '@/lib/dirs';
|
|
15
26
|
|
|
27
|
+
type Category = 'cloned-projects' | 'tmp' | 'scratch';
|
|
28
|
+
interface CacheEntry { name: string; bytes: number; category: Category }
|
|
29
|
+
|
|
16
30
|
function dirSizeBytes(p: string): number {
|
|
17
31
|
let total = 0;
|
|
18
32
|
try {
|
|
@@ -27,16 +41,89 @@ function dirSizeBytes(p: string): number {
|
|
|
27
41
|
return total;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
|
-
function listCache(): { entries:
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
function listCache(): { entries: CacheEntry[]; total_bytes: number; roots: Record<Category, string> } {
|
|
45
|
+
const dataDir = getDataDir();
|
|
46
|
+
const clonedRoot = join(dataDir, 'cloned-projects');
|
|
47
|
+
const tmpRoot = join(dataDir, 'tmp');
|
|
48
|
+
const scratchRoot = join(dataDir, 'scratch');
|
|
49
|
+
const entries: CacheEntry[] = [];
|
|
50
|
+
|
|
51
|
+
if (existsSync(clonedRoot)) {
|
|
52
|
+
for (const ent of readdirSync(clonedRoot, { withFileTypes: true })) {
|
|
53
|
+
if (!ent.isDirectory()) continue;
|
|
54
|
+
entries.push({ name: ent.name, bytes: dirSizeBytes(join(clonedRoot, ent.name)), category: 'cloned-projects' });
|
|
55
|
+
}
|
|
37
56
|
}
|
|
57
|
+
if (existsSync(tmpRoot)) {
|
|
58
|
+
for (const ent of readdirSync(tmpRoot, { withFileTypes: true })) {
|
|
59
|
+
// tmp/ holds bare files written by save_tmp_file (the tool rejects
|
|
60
|
+
// any filename containing `/`). Subdirs aren't expected — if one
|
|
61
|
+
// shows up (manual touch, older code) measure recursively so the
|
|
62
|
+
// entry is still wipable from the UI rather than left dangling.
|
|
63
|
+
if (ent.name.startsWith('.')) continue;
|
|
64
|
+
const p = join(tmpRoot, ent.name);
|
|
65
|
+
try {
|
|
66
|
+
const st = statSync(p);
|
|
67
|
+
const bytes = st.isDirectory() ? dirSizeBytes(p) : st.size;
|
|
68
|
+
entries.push({ name: ent.name, bytes, category: 'tmp' });
|
|
69
|
+
} catch { /* skip unreadable */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (existsSync(scratchRoot)) {
|
|
73
|
+
// scratch/ has two coexisting kinds of content:
|
|
74
|
+
// 1. Top-level files from legacy save_scratch_file (now renamed
|
|
75
|
+
// save_tmp_file → tmp/). Janitor sweeps these at 7d. Listed.
|
|
76
|
+
// 2. Subdirs that are LIVE workdirs for dispatched tasks (every
|
|
77
|
+
// dispatch_task into the scratch project gets one). Deleting
|
|
78
|
+
// these mid-run breaks the running task — NOT listed.
|
|
79
|
+
// CLAUDE.md is the scratch-project marker; preserved by janitor and
|
|
80
|
+
// hidden here so users can't nuke it from the UI.
|
|
81
|
+
for (const ent of readdirSync(scratchRoot, { withFileTypes: true })) {
|
|
82
|
+
if (ent.name.startsWith('.') || ent.name === 'CLAUDE.md') continue;
|
|
83
|
+
if (!ent.isFile()) continue;
|
|
84
|
+
const p = join(scratchRoot, ent.name);
|
|
85
|
+
try {
|
|
86
|
+
const st = statSync(p);
|
|
87
|
+
entries.push({ name: ent.name, bytes: st.size, category: 'scratch' });
|
|
88
|
+
} catch { /* skip unreadable */ }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
38
92
|
entries.sort((a, b) => b.bytes - a.bytes);
|
|
39
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
entries,
|
|
95
|
+
total_bytes: entries.reduce((s, e) => s + e.bytes, 0),
|
|
96
|
+
roots: { 'cloned-projects': clonedRoot, tmp: tmpRoot, scratch: scratchRoot },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isValidName(name: string): boolean {
|
|
101
|
+
return !!name && !name.includes('/') && !name.includes('\\') && !name.includes('..');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rmEntry(category: Category, name: string, dataDir: string): { ok: boolean; bytes: number; error?: string } {
|
|
105
|
+
// scratch/CLAUDE.md is the project marker — refuse to delete even if
|
|
106
|
+
// a caller asks for it by name. Same instinct as the listCache filter.
|
|
107
|
+
if (category === 'scratch' && (name === 'CLAUDE.md' || name.startsWith('.'))) {
|
|
108
|
+
return { ok: false, bytes: 0, error: 'refused: scratch marker / dotfile' };
|
|
109
|
+
}
|
|
110
|
+
const root = join(dataDir, category);
|
|
111
|
+
const target = join(root, name);
|
|
112
|
+
if (!existsSync(target)) return { ok: false, bytes: 0, error: 'not found' };
|
|
113
|
+
let bytes = 0;
|
|
114
|
+
try {
|
|
115
|
+
const st = statSync(target);
|
|
116
|
+
// scratch/ category only deletes top-level files — never the dir
|
|
117
|
+
// contents of a task workdir subdir. listCache already hides subdirs;
|
|
118
|
+
// this is belt-and-suspenders for direct API callers.
|
|
119
|
+
if (category === 'scratch' && st.isDirectory()) {
|
|
120
|
+
return { ok: false, bytes: 0, error: 'refused: scratch subdir is a live task workdir, not cache' };
|
|
121
|
+
}
|
|
122
|
+
bytes = st.isDirectory() ? dirSizeBytes(target) : st.size;
|
|
123
|
+
if (st.isDirectory()) rmSync(target, { recursive: true, force: true });
|
|
124
|
+
else unlinkSync(target);
|
|
125
|
+
} catch (e) { return { ok: false, bytes: 0, error: (e as Error).message }; }
|
|
126
|
+
return { ok: true, bytes };
|
|
40
127
|
}
|
|
41
128
|
|
|
42
129
|
export async function GET() {
|
|
@@ -46,38 +133,35 @@ export async function GET() {
|
|
|
46
133
|
export async function DELETE(req: Request) {
|
|
47
134
|
const url = new URL(req.url);
|
|
48
135
|
const name = url.searchParams.get('name');
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
// Reject anything that escapes the cache root or contains separators —
|
|
54
|
-
// we accept ONLY a direct subdirectory name (e.g. "fortinet-fortinac-dev").
|
|
55
|
-
if (name && (name.includes('/') || name.includes('\\') || name.includes('..'))) {
|
|
136
|
+
const categoryParam = url.searchParams.get('category') as Category | null;
|
|
137
|
+
const dataDir = getDataDir();
|
|
138
|
+
|
|
139
|
+
if (name && !isValidName(name)) {
|
|
56
140
|
return NextResponse.json({ ok: false, error: 'invalid name' }, { status: 400 });
|
|
57
141
|
}
|
|
58
142
|
|
|
59
|
-
|
|
143
|
+
// Single-entry delete: scan all categories if category not specified,
|
|
144
|
+
// so the existing Dashboard "delete by name" call (no category) keeps
|
|
145
|
+
// working — finds the entry in whichever category owns it.
|
|
60
146
|
if (name) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} catch (e) {
|
|
69
|
-
return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
|
|
147
|
+
const cats: Category[] = categoryParam ? [categoryParam] : ['cloned-projects', 'tmp', 'scratch'];
|
|
148
|
+
for (const cat of cats) {
|
|
149
|
+
const r = rmEntry(cat, name, dataDir);
|
|
150
|
+
if (r.ok) return NextResponse.json({ ok: true, deleted: 1, freed_bytes: r.bytes, category: cat });
|
|
151
|
+
if (r.error && r.error !== 'not found') {
|
|
152
|
+
return NextResponse.json({ ok: false, error: r.error }, { status: 500 });
|
|
153
|
+
}
|
|
70
154
|
}
|
|
71
|
-
return NextResponse.json({ ok:
|
|
155
|
+
return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
|
|
72
156
|
}
|
|
73
157
|
|
|
74
|
-
// Wipe
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
158
|
+
// Wipe-all (optionally scoped to one category).
|
|
159
|
+
const before = listCache();
|
|
160
|
+
const toDelete = categoryParam ? before.entries.filter((e) => e.category === categoryParam) : before.entries;
|
|
161
|
+
let deleted = 0, freed = 0;
|
|
162
|
+
for (const e of toDelete) {
|
|
163
|
+
const r = rmEntry(e.category, e.name, dataDir);
|
|
164
|
+
if (r.ok) { deleted++; freed += r.bytes; }
|
|
81
165
|
}
|
|
82
|
-
return NextResponse.json({ ok: true, deleted, freed_bytes:
|
|
166
|
+
return NextResponse.json({ ok: true, deleted, freed_bytes: freed });
|
|
83
167
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/chat/sessions/<id>/abort → 200 { ok, running } | 409
|
|
3
|
+
*
|
|
4
|
+
* Stops the in-flight tool-call loop for this session. The chat-standalone
|
|
5
|
+
* agent loop breaks at the next iteration boundary and persists a
|
|
6
|
+
* "⏹ Stopped by user" assistant message. 409 if no turn is running.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { proxyToChat } from '@/lib/chat/proxy';
|
|
10
|
+
|
|
11
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
12
|
+
const { id } = await params;
|
|
13
|
+
return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}/abort`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/chat/sessions/<id>/note → 200 { ok, running } | 409
|
|
3
|
+
*
|
|
4
|
+
* Queues supplementary text for the running turn. The chat-standalone
|
|
5
|
+
* agent loop drains queued notes at the top of its next iteration and
|
|
6
|
+
* splices them in as a user message so the LLM sees them on its next
|
|
7
|
+
* step. 409 if no turn is running (client should send as a regular
|
|
8
|
+
* message via POST /messages instead — which also auto-merges).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { proxyToChat } from '@/lib/chat/proxy';
|
|
12
|
+
|
|
13
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
14
|
+
const { id } = await params;
|
|
15
|
+
return proxyToChat(req, `/api/sessions/${encodeURIComponent(id)}/note`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/files/<path...>
|
|
3
|
+
* ?download=1 → force Content-Disposition: attachment
|
|
4
|
+
*
|
|
5
|
+
* Dataset-wide file reader. Path is dataDir-relative — `tmp/foo.md`,
|
|
6
|
+
* `scratch/report.md`, `flows/x.yaml`, etc. Pairs with the chat builtin
|
|
7
|
+
* `read_forge_file` and the in-browser viewer at /files/<path>.
|
|
8
|
+
*
|
|
9
|
+
* Sensitive top-level items (encryption keys, sqlite DBs, server log,
|
|
10
|
+
* `*-tokens.json`) are refused — same blacklist as read_forge_file so
|
|
11
|
+
* chat-emitted links can't accidentally expose secrets.
|
|
12
|
+
*
|
|
13
|
+
* /api/scratch/<path> is the older route that resolves under
|
|
14
|
+
* <dataDir>/scratch/ only — kept for legacy URLs / link-pattern emits.
|
|
15
|
+
*
|
|
16
|
+
* Auth-gated by Forge's proxy middleware just like every other /api/*.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { NextResponse } from 'next/server';
|
|
20
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { join, resolve, extname } from 'node:path';
|
|
22
|
+
import { getDataDir } from '@/lib/dirs';
|
|
23
|
+
|
|
24
|
+
const MIME: Record<string, string> = {
|
|
25
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
26
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
27
|
+
'.json': 'application/json; charset=utf-8',
|
|
28
|
+
'.yaml': 'application/yaml; charset=utf-8',
|
|
29
|
+
'.yml': 'application/yaml; charset=utf-8',
|
|
30
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
31
|
+
'.log': 'text/plain; charset=utf-8',
|
|
32
|
+
'.html': 'text/html; charset=utf-8',
|
|
33
|
+
'.pdf': 'application/pdf',
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.jpg': 'image/jpeg',
|
|
36
|
+
'.jpeg': 'image/jpeg',
|
|
37
|
+
'.gif': 'image/gif',
|
|
38
|
+
'.svg': 'image/svg+xml',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function isSensitiveTop(top: string): string | null {
|
|
42
|
+
if (top.startsWith('.')) return 'dotfile';
|
|
43
|
+
if (/\.(db|db-wal|db-shm)$/i.test(top)) return 'sqlite';
|
|
44
|
+
if (/^forge\.log/i.test(top)) return 'log';
|
|
45
|
+
if (/-tokens\.json$/i.test(top)) return 'token cache';
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function GET(
|
|
50
|
+
req: Request,
|
|
51
|
+
ctx: { params: Promise<{ path: string[] }> },
|
|
52
|
+
) {
|
|
53
|
+
const { path: parts } = await ctx.params;
|
|
54
|
+
if (!parts || parts.length === 0) {
|
|
55
|
+
return NextResponse.json({ ok: false, error: 'missing path' }, { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
for (const seg of parts) {
|
|
58
|
+
if (seg === '..' || seg.startsWith('..')) {
|
|
59
|
+
return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const top = parts[0]!;
|
|
63
|
+
const blocked = isSensitiveTop(top);
|
|
64
|
+
if (blocked) {
|
|
65
|
+
return NextResponse.json({ ok: false, error: `top-level "${top}" is sensitive (${blocked})` }, { status: 403 });
|
|
66
|
+
}
|
|
67
|
+
const dataRoot = resolve(getDataDir());
|
|
68
|
+
const target = resolve(join(dataRoot, ...parts));
|
|
69
|
+
if (!target.startsWith(dataRoot + '/') && target !== dataRoot) {
|
|
70
|
+
return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
|
|
71
|
+
}
|
|
72
|
+
if (!existsSync(target)) {
|
|
73
|
+
return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
|
|
74
|
+
}
|
|
75
|
+
const st = statSync(target);
|
|
76
|
+
if (!st.isFile()) {
|
|
77
|
+
return NextResponse.json({ ok: false, error: 'not a file' }, { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
const url = new URL(req.url);
|
|
80
|
+
const download = url.searchParams.get('download') === '1';
|
|
81
|
+
const ext = extname(target).toLowerCase();
|
|
82
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
83
|
+
const data = readFileSync(target);
|
|
84
|
+
const filename = parts[parts.length - 1];
|
|
85
|
+
return new NextResponse(new Uint8Array(data), {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: {
|
|
88
|
+
'Content-Type': mime,
|
|
89
|
+
'Content-Length': String(st.size),
|
|
90
|
+
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${filename.replace(/"/g, '\\"')}"`,
|
|
91
|
+
'Cache-Control': 'private, max-age=0, must-revalidate',
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* Path-traversal protected: any segment containing `..` or any resolved
|
|
8
8
|
* path escaping the scratch root is rejected.
|
|
9
9
|
*
|
|
10
|
+
* For files outside scratch/ (e.g. `tmp/foo.md` from save_tmp_file,
|
|
11
|
+
* `flows/x.yaml`) the dataDir-wide route at /api/files/[...path] is the
|
|
12
|
+
* one to use — it shares the same MIME / download / size guards but
|
|
13
|
+
* roots at <dataDir>/ with a sensitive-file blacklist.
|
|
14
|
+
*
|
|
10
15
|
* Auth-gated by Forge's proxy middleware just like every other /api/*.
|
|
11
16
|
*/
|
|
12
17
|
|