@aion0/forge 0.10.51 → 0.10.55

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,8 @@
1
- # Forge v0.10.51
1
+ # Forge v0.10.55
2
2
 
3
3
  Released: 2026-06-09
4
4
 
5
- ## Changes since v0.10.50
5
+ ## Changes since v0.10.54
6
6
 
7
7
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.50...v0.10.51
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.54...v0.10.55
@@ -1,18 +1,32 @@
1
1
  /**
2
- * GET /api/cache → list cached cloned-project dirs + total size.
3
- * DELETE /api/cache → wipe the whole cloned-projects cache.
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. Same data the CLI's
7
- * `forge clean cache` shows, surfaced in the UI for users who don't
8
- * touch the terminal.
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: { name: string; bytes: number }[]; total_bytes: number; root: string } {
31
- const root = join(getDataDir(), 'cloned-projects');
32
- if (!existsSync(root)) return { entries: [], total_bytes: 0, root };
33
- const entries: { name: string; bytes: number }[] = [];
34
- for (const ent of readdirSync(root, { withFileTypes: true })) {
35
- if (!ent.isDirectory()) continue;
36
- entries.push({ name: ent.name, bytes: dirSizeBytes(join(root, ent.name)) });
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 { entries, total_bytes: entries.reduce((s, e) => s + e.bytes, 0), root };
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 root = join(getDataDir(), 'cloned-projects');
50
- if (!existsSync(root)) {
51
- return NextResponse.json({ ok: true, deleted: 0, freed_bytes: 0 });
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
- const before = listCache();
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 target = join(root, name);
62
- if (!existsSync(target)) {
63
- return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
64
- }
65
- const hit = before.entries.find((e) => e.name === name);
66
- try {
67
- rmSync(target, { recursive: true, force: true });
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: true, deleted: 1, freed_bytes: hit?.bytes || 0 });
155
+ return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
72
156
  }
73
157
 
74
- // Wipe all
75
- let deleted = 0;
76
- for (const e of before.entries) {
77
- try {
78
- rmSync(join(root, e.name), { recursive: true, force: true });
79
- deleted++;
80
- } catch { /* skip individual failures, report partial */ }
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: before.total_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