aicodeman 0.6.9 → 0.6.11
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/dist/web/paste-image-gc.d.ts +7 -0
- package/dist/web/paste-image-gc.d.ts.map +1 -0
- package/dist/web/paste-image-gc.js +69 -0
- package/dist/web/paste-image-gc.js.map +1 -0
- package/dist/web/public/api-client.3adebdc2.js.gz +0 -0
- package/dist/web/public/{app.2353feb8.js → app.6a96cf81.js} +8 -10
- package/dist/web/public/app.6a96cf81.js.br +0 -0
- package/dist/web/public/app.6a96cf81.js.gz +0 -0
- package/dist/web/public/{constants.35154472.js → constants.cb6426c4.js} +46 -0
- package/dist/web/public/constants.cb6426c4.js.br +0 -0
- package/dist/web/public/constants.cb6426c4.js.gz +0 -0
- package/dist/web/public/{image-input.2d862e9c.js → image-input.926911b4.js} +7 -2
- package/dist/web/public/image-input.926911b4.js.br +0 -0
- package/dist/web/public/image-input.926911b4.js.gz +0 -0
- package/dist/web/public/index.html +7 -7
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.88082175.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.29aebd9c.js.gz +0 -0
- package/dist/web/public/mobile-handlers.1e2a8ef8.js.gz +0 -0
- package/dist/web/public/mobile.37d62c06.css.gz +0 -0
- package/dist/web/public/notification-manager.9c984ac2.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.cf998835.js.gz +0 -0
- package/dist/web/public/ralph-panel.61076370.js.gz +0 -0
- package/dist/web/public/ralph-wizard.6b0f0be7.js.gz +0 -0
- package/dist/web/public/respawn-ui.5377f958.js.gz +0 -0
- package/dist/web/public/session-ui.f1555cd1.js.gz +0 -0
- package/dist/web/public/settings-ui.25a18120.js.gz +0 -0
- package/dist/web/public/styles.d160ad58.css +1 -0
- package/dist/web/public/styles.d160ad58.css.br +0 -0
- package/dist/web/public/styles.d160ad58.css.gz +0 -0
- package/dist/web/public/subagent-windows.a366a4ad.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/terminal-ui.5d29101f.js +3 -0
- package/dist/web/public/terminal-ui.5d29101f.js.br +0 -0
- package/dist/web/public/terminal-ui.5d29101f.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
- package/dist/web/routes/session-routes.d.ts +12 -0
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +312 -139
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/server.d.ts +1 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +27 -5
- package/dist/web/server.js.map +1 -1
- package/package.json +2 -1
- package/dist/web/public/app.2353feb8.js.br +0 -0
- package/dist/web/public/app.2353feb8.js.gz +0 -0
- package/dist/web/public/constants.35154472.js.br +0 -0
- package/dist/web/public/constants.35154472.js.gz +0 -0
- package/dist/web/public/image-input.2d862e9c.js.br +0 -0
- package/dist/web/public/image-input.2d862e9c.js.gz +0 -0
- package/dist/web/public/styles.1f5114f6.css +0 -1
- package/dist/web/public/styles.1f5114f6.css.br +0 -0
- package/dist/web/public/styles.1f5114f6.css.gz +0 -0
- package/dist/web/public/terminal-ui.d069d610.js +0 -3
- package/dist/web/public/terminal-ui.d069d610.js.br +0 -0
- package/dist/web/public/terminal-ui.d069d610.js.gz +0 -0
|
@@ -8,6 +8,7 @@ import { homedir } from 'node:os';
|
|
|
8
8
|
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
10
|
import fs from 'node:fs/promises';
|
|
11
|
+
import { randomBytes } from 'node:crypto';
|
|
11
12
|
import { ApiErrorCode, createErrorResponse, getErrorMessage, } from '../../types.js';
|
|
12
13
|
import { Session } from '../../session.js';
|
|
13
14
|
import { SseEvent } from '../sse-events.js';
|
|
@@ -86,6 +87,73 @@ export function stripInkRedrawBloat(buffer) {
|
|
|
86
87
|
parts.push(buffer.slice(cursor));
|
|
87
88
|
return parts.join('');
|
|
88
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
|
|
92
|
+
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
|
|
93
|
+
* SVG disguised under a `Content-Type: image/png` header) and against simple
|
|
94
|
+
* extension-only spoofing — both the multipart filename and the Content-Type
|
|
95
|
+
* are attacker-controlled, the raw bytes are not.
|
|
96
|
+
*
|
|
97
|
+
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
|
|
98
|
+
*/
|
|
99
|
+
export function imageMagicMatchesExt(data, ext) {
|
|
100
|
+
if (data.length < 12)
|
|
101
|
+
return false;
|
|
102
|
+
const u32be = (off) => data.readUInt32BE(off);
|
|
103
|
+
switch (ext) {
|
|
104
|
+
case '.png':
|
|
105
|
+
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
|
|
106
|
+
case '.jpg':
|
|
107
|
+
case '.jpeg':
|
|
108
|
+
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
|
109
|
+
case '.gif':
|
|
110
|
+
return (data[0] === 0x47 &&
|
|
111
|
+
data[1] === 0x49 &&
|
|
112
|
+
data[2] === 0x46 &&
|
|
113
|
+
data[3] === 0x38 &&
|
|
114
|
+
(data[4] === 0x37 || data[4] === 0x39) &&
|
|
115
|
+
data[5] === 0x61);
|
|
116
|
+
case '.webp':
|
|
117
|
+
// RIFF....WEBP
|
|
118
|
+
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
|
|
119
|
+
case '.bmp':
|
|
120
|
+
return data[0] === 0x42 && data[1] === 0x4d;
|
|
121
|
+
default:
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
|
126
|
+
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
|
127
|
+
// against a flood of unique IP keys.
|
|
128
|
+
const PASTE_RATE_TOKENS = 30;
|
|
129
|
+
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
|
|
130
|
+
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
|
131
|
+
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
|
132
|
+
const pasteRateBuckets = new Map();
|
|
133
|
+
export function consumePasteToken(key, now = Date.now()) {
|
|
134
|
+
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
|
135
|
+
for (const [k, b] of pasteRateBuckets) {
|
|
136
|
+
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS)
|
|
137
|
+
pasteRateBuckets.delete(k);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let b = pasteRateBuckets.get(key);
|
|
141
|
+
if (!b) {
|
|
142
|
+
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
|
|
143
|
+
pasteRateBuckets.set(key, b);
|
|
144
|
+
}
|
|
145
|
+
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
|
|
146
|
+
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
|
147
|
+
b.lastRefill = now;
|
|
148
|
+
if (b.tokens < 1)
|
|
149
|
+
return false;
|
|
150
|
+
b.tokens -= 1;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
// Test hook: reset between runs.
|
|
154
|
+
export function _resetPasteRateBuckets() {
|
|
155
|
+
pasteRateBuckets.clear();
|
|
156
|
+
}
|
|
89
157
|
export function registerSessionRoutes(app, ctx) {
|
|
90
158
|
// ═══════════════════════════════════════════════════════════════
|
|
91
159
|
// Auth
|
|
@@ -1077,36 +1145,76 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1077
1145
|
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
|
1078
1146
|
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
|
1079
1147
|
*
|
|
1080
|
-
* Strategy:
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1148
|
+
* Strategy: recursive backtracking with longest-match-first preference.
|
|
1149
|
+
* At each segment boundary, try joining as many segments as possible (with '_'
|
|
1150
|
+
* or '-') into a single existing directory name. If a shorter match leads to a
|
|
1151
|
+
* dead end, backtrack and try the next-shorter candidate.
|
|
1152
|
+
*
|
|
1153
|
+
* Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
|
|
1154
|
+
* naive shortest-match would pick `diary` and then fail to find `app` inside,
|
|
1155
|
+
* leaving the rest of the key unresolved. Longest-first picks `diary-app`.
|
|
1084
1156
|
*/
|
|
1085
1157
|
async function decodeProjectKey(projKey) {
|
|
1086
1158
|
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
|
1087
1159
|
const segments = encoded.split('-');
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1160
|
+
const isDirCache = new Map();
|
|
1161
|
+
const isDir = async (p) => {
|
|
1162
|
+
const cached = isDirCache.get(p);
|
|
1163
|
+
if (cached !== undefined)
|
|
1164
|
+
return cached;
|
|
1165
|
+
const result = await fs
|
|
1166
|
+
.stat(p)
|
|
1167
|
+
.then((s) => s.isDirectory())
|
|
1168
|
+
.catch(() => false);
|
|
1169
|
+
isDirCache.set(p, result);
|
|
1170
|
+
return result;
|
|
1171
|
+
};
|
|
1172
|
+
// Recursive backtracking: returns the deepest valid path that consumes all
|
|
1173
|
+
// segments. Tries the longest segment-join first at each step so that
|
|
1174
|
+
// dash-containing directory names win over shorter same-prefix siblings.
|
|
1175
|
+
async function tryDecode(idx, current) {
|
|
1176
|
+
if (idx >= segments.length)
|
|
1177
|
+
return current;
|
|
1178
|
+
const maxLook = Math.min(idx + 4, segments.length);
|
|
1179
|
+
// Longest first: end = maxLook-1 down to idx
|
|
1180
|
+
for (let end = maxLook - 1; end >= idx; end--) {
|
|
1181
|
+
const candidates = [];
|
|
1182
|
+
if (end === idx) {
|
|
1183
|
+
candidates.push(segments[idx]);
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
candidates.push(segments.slice(idx, end + 1).join('-'));
|
|
1187
|
+
candidates.push(segments.slice(idx, end + 1).join('_'));
|
|
1188
|
+
}
|
|
1189
|
+
for (const child of candidates) {
|
|
1190
|
+
const candidate = current + '/' + child;
|
|
1191
|
+
if (await isDir(candidate)) {
|
|
1192
|
+
const result = await tryDecode(end + 1, candidate);
|
|
1193
|
+
if (result)
|
|
1194
|
+
return result;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
const decoded = await tryDecode(0, '');
|
|
1201
|
+
if (decoded)
|
|
1202
|
+
return decoded;
|
|
1203
|
+
// Fallback: greedy shortest-match (original behavior) — best effort when
|
|
1204
|
+
// no fully-valid path exists (e.g. directory was deleted after the
|
|
1205
|
+
// conversation was recorded).
|
|
1092
1206
|
let current = '';
|
|
1093
1207
|
let i = 0;
|
|
1094
1208
|
while (i < segments.length) {
|
|
1095
|
-
// Try progressively longer child names by joining segments with '_' or '-'
|
|
1096
1209
|
let matched = false;
|
|
1097
|
-
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
|
|
1098
1210
|
const maxLook = Math.min(i + 4, segments.length);
|
|
1099
1211
|
for (let end = i; end < maxLook; end++) {
|
|
1100
|
-
// Build candidate child name from segments[i..end]
|
|
1101
|
-
// Try all separator combinations: for 2+ segments, try '_' first then '-'
|
|
1102
1212
|
const candidates = [];
|
|
1103
1213
|
if (end === i) {
|
|
1104
1214
|
candidates.push(segments[i]);
|
|
1105
1215
|
}
|
|
1106
1216
|
else {
|
|
1107
|
-
// Build with underscores between joined segments
|
|
1108
1217
|
candidates.push(segments.slice(i, end + 1).join('_'));
|
|
1109
|
-
// Build with dashes (literal)
|
|
1110
1218
|
candidates.push(segments.slice(i, end + 1).join('-'));
|
|
1111
1219
|
}
|
|
1112
1220
|
for (const child of candidates) {
|
|
@@ -1122,7 +1230,6 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1122
1230
|
break;
|
|
1123
1231
|
}
|
|
1124
1232
|
if (!matched) {
|
|
1125
|
-
// No directory match found — append as-is and move on
|
|
1126
1233
|
current = current + '/' + segments[i];
|
|
1127
1234
|
i++;
|
|
1128
1235
|
}
|
|
@@ -1164,156 +1271,190 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1164
1271
|
return null;
|
|
1165
1272
|
}
|
|
1166
1273
|
}
|
|
1167
|
-
|
|
1274
|
+
// Scan a single project directory and return all valid history sessions in it.
|
|
1275
|
+
// Reused by both the global overview and the single-folder drill-down.
|
|
1276
|
+
async function scanProjectDir(projPath, projDir, headBuf) {
|
|
1277
|
+
const out = [];
|
|
1278
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
1279
|
+
if (!stat?.isDirectory())
|
|
1280
|
+
return out;
|
|
1281
|
+
const workingDir = await decodeProjectKey(projDir);
|
|
1282
|
+
const entries = await fs.readdir(projPath).catch(() => []);
|
|
1283
|
+
for (const entry of entries) {
|
|
1284
|
+
if (!entry.endsWith('.jsonl'))
|
|
1285
|
+
continue;
|
|
1286
|
+
const sessionId = entry.replace('.jsonl', '');
|
|
1287
|
+
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId))
|
|
1288
|
+
continue;
|
|
1289
|
+
const filePath = join(projPath, entry);
|
|
1290
|
+
const fileStat = await fs.stat(filePath).catch(() => null);
|
|
1291
|
+
if (!fileStat)
|
|
1292
|
+
continue;
|
|
1293
|
+
if (fileStat.size < 4000)
|
|
1294
|
+
continue;
|
|
1295
|
+
let firstPrompt;
|
|
1296
|
+
const head = await readFileHead(filePath, headBuf);
|
|
1297
|
+
const hasConversation = (text) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
|
1298
|
+
let foundContent = head ? hasConversation(head) : false;
|
|
1299
|
+
let tail = null;
|
|
1300
|
+
if (!foundContent && fileStat.size > 16384) {
|
|
1301
|
+
const tailBuf = Buffer.alloc(32768);
|
|
1302
|
+
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
|
1303
|
+
if (tail)
|
|
1304
|
+
foundContent = hasConversation(tail);
|
|
1305
|
+
}
|
|
1306
|
+
if (!foundContent)
|
|
1307
|
+
continue;
|
|
1308
|
+
if (head)
|
|
1309
|
+
firstPrompt = extractFirstUserPrompt(head);
|
|
1310
|
+
if (!firstPrompt && fileStat.size > 65536) {
|
|
1311
|
+
if (!tail) {
|
|
1312
|
+
const tailBuf = Buffer.alloc(32768);
|
|
1313
|
+
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
|
1314
|
+
}
|
|
1315
|
+
if (tail)
|
|
1316
|
+
firstPrompt = extractFirstUserPrompt(tail);
|
|
1317
|
+
}
|
|
1318
|
+
out.push({
|
|
1319
|
+
sessionId,
|
|
1320
|
+
workingDir,
|
|
1321
|
+
projectKey: projDir,
|
|
1322
|
+
sizeBytes: fileStat.size,
|
|
1323
|
+
lastModified: fileStat.mtime.toISOString(),
|
|
1324
|
+
firstPrompt,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
return out;
|
|
1328
|
+
}
|
|
1329
|
+
app.get('/api/history/sessions', async (req) => {
|
|
1330
|
+
const query = req.query;
|
|
1168
1331
|
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
|
1169
|
-
const results = [];
|
|
1170
1332
|
const headBuf = Buffer.alloc(16384);
|
|
1333
|
+
// Single-folder drill-down: when projectKey is provided, scan only that
|
|
1334
|
+
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
|
1335
|
+
if (query.projectKey) {
|
|
1336
|
+
// Validate projectKey format to prevent path traversal
|
|
1337
|
+
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
|
|
1338
|
+
return { sessions: [], total: 0 };
|
|
1339
|
+
}
|
|
1340
|
+
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
|
1341
|
+
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
|
1342
|
+
const projPath = join(projectsDir, query.projectKey);
|
|
1343
|
+
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
|
1344
|
+
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
1345
|
+
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
|
1346
|
+
}
|
|
1347
|
+
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
|
1348
|
+
const results = [];
|
|
1171
1349
|
try {
|
|
1172
1350
|
const projectDirs = await fs.readdir(projectsDir);
|
|
1173
1351
|
for (const projDir of projectDirs) {
|
|
1174
1352
|
const projPath = join(projectsDir, projDir);
|
|
1175
|
-
const
|
|
1176
|
-
|
|
1177
|
-
continue;
|
|
1178
|
-
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
|
1179
|
-
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
|
1180
|
-
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
|
1181
|
-
// verify which decoded path actually exists on disk.
|
|
1182
|
-
const workingDir = await decodeProjectKey(projDir);
|
|
1183
|
-
const entries = await fs.readdir(projPath);
|
|
1184
|
-
for (const entry of entries) {
|
|
1185
|
-
if (!entry.endsWith('.jsonl'))
|
|
1186
|
-
continue;
|
|
1187
|
-
const sessionId = entry.replace('.jsonl', '');
|
|
1188
|
-
// Only valid UUIDs
|
|
1189
|
-
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId))
|
|
1190
|
-
continue;
|
|
1191
|
-
const filePath = join(projPath, entry);
|
|
1192
|
-
const fileStat = await fs.stat(filePath).catch(() => null);
|
|
1193
|
-
if (!fileStat)
|
|
1194
|
-
continue;
|
|
1195
|
-
// Skip files too small to contain real conversation (metadata-only sessions
|
|
1196
|
-
// like file-history-snapshot entries are typically < 4KB)
|
|
1197
|
-
if (fileStat.size < 4000)
|
|
1198
|
-
continue;
|
|
1199
|
-
// Quick content check: verify actual conversation data exists.
|
|
1200
|
-
// Sessions with only file-history-snapshot or hook_progress entries have
|
|
1201
|
-
// no "user"/"assistant" messages and will fail claude --resume.
|
|
1202
|
-
// Read first 16KB to check content and extract first user prompt.
|
|
1203
|
-
let firstPrompt;
|
|
1204
|
-
const head = await readFileHead(filePath, headBuf);
|
|
1205
|
-
const hasConversation = (text) => text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
|
1206
|
-
let foundContent = head ? hasConversation(head) : false;
|
|
1207
|
-
// For large files, head may not contain user messages (e.g. /init followed
|
|
1208
|
-
// by large system entries). Check the tail as well.
|
|
1209
|
-
let tail = null;
|
|
1210
|
-
if (!foundContent && fileStat.size > 16384) {
|
|
1211
|
-
const tailBuf = Buffer.alloc(32768);
|
|
1212
|
-
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
|
1213
|
-
if (tail)
|
|
1214
|
-
foundContent = hasConversation(tail);
|
|
1215
|
-
}
|
|
1216
|
-
if (!foundContent)
|
|
1217
|
-
continue; // No conversation content — skip
|
|
1218
|
-
if (head)
|
|
1219
|
-
firstPrompt = extractFirstUserPrompt(head);
|
|
1220
|
-
// If head scan found no usable prompt (e.g. session started with /init),
|
|
1221
|
-
// try reading the tail for a recent user message.
|
|
1222
|
-
if (!firstPrompt && fileStat.size > 65536) {
|
|
1223
|
-
if (!tail) {
|
|
1224
|
-
const tailBuf = Buffer.alloc(32768);
|
|
1225
|
-
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
|
1226
|
-
}
|
|
1227
|
-
if (tail)
|
|
1228
|
-
firstPrompt = extractFirstUserPrompt(tail);
|
|
1229
|
-
}
|
|
1230
|
-
results.push({
|
|
1231
|
-
sessionId,
|
|
1232
|
-
workingDir,
|
|
1233
|
-
projectKey: projDir,
|
|
1234
|
-
sizeBytes: fileStat.size,
|
|
1235
|
-
lastModified: fileStat.mtime.toISOString(),
|
|
1236
|
-
firstPrompt,
|
|
1237
|
-
});
|
|
1238
|
-
}
|
|
1353
|
+
const list = await scanProjectDir(projPath, projDir, headBuf);
|
|
1354
|
+
results.push(...list);
|
|
1239
1355
|
}
|
|
1240
1356
|
}
|
|
1241
1357
|
catch {
|
|
1242
1358
|
// Projects dir may not exist
|
|
1243
1359
|
}
|
|
1244
|
-
// Sort by lastModified descending
|
|
1245
1360
|
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
|
1246
1361
|
return { sessions: results.slice(0, 50) };
|
|
1247
1362
|
});
|
|
1248
1363
|
// ═══════════════════════════════════════════════════════════════
|
|
1249
1364
|
// Paste Image (clipboard / drag-drop upload)
|
|
1250
1365
|
// ═══════════════════════════════════════════════════════════════
|
|
1251
|
-
const MAX_PASTE_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
1252
1366
|
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
|
1367
|
+
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
|
|
1253
1368
|
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
|
1369
|
+
// CSRF defense: state-changing routes must come from same origin.
|
|
1370
|
+
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
|
|
1371
|
+
// (no preflight), so a cross-origin <form enctype="multipart/form-data">
|
|
1372
|
+
// submit attaches the session cookie unimpeded. Reject unless Origin/Referer
|
|
1373
|
+
// matches req.host. Non-browser clients (no Origin AND no Referer) must
|
|
1374
|
+
// supply X-Codeman-CSRF — a header browsers cannot add cross-origin without
|
|
1375
|
+
// a preflight, which our CORS config does not allow from other origins.
|
|
1376
|
+
const reqHost = req.headers.host;
|
|
1377
|
+
const origin = req.headers.origin;
|
|
1378
|
+
const referer = req.headers.referer;
|
|
1379
|
+
let csrfOk = false;
|
|
1380
|
+
if (origin) {
|
|
1381
|
+
try {
|
|
1382
|
+
csrfOk = new URL(origin).host === reqHost;
|
|
1383
|
+
}
|
|
1384
|
+
catch {
|
|
1385
|
+
/* invalid Origin → not ok */
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
else if (referer) {
|
|
1389
|
+
try {
|
|
1390
|
+
csrfOk = new URL(referer).host === reqHost;
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
/* invalid Referer → not ok */
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
else {
|
|
1397
|
+
csrfOk = !!req.headers['x-codeman-csrf'];
|
|
1398
|
+
}
|
|
1399
|
+
if (!csrfOk) {
|
|
1400
|
+
reply.code(403);
|
|
1401
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
|
|
1402
|
+
}
|
|
1254
1403
|
const { id } = req.params;
|
|
1404
|
+
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
|
1405
|
+
// — even an authenticated attacker can otherwise loop 10MB POSTs.
|
|
1406
|
+
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
|
1407
|
+
reply.code(429);
|
|
1408
|
+
reply.header('Retry-After', '60');
|
|
1409
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
|
1410
|
+
}
|
|
1255
1411
|
const session = findSessionOrFail(ctx, id);
|
|
1256
|
-
|
|
1257
|
-
if (!contentType.includes('multipart/form-data')) {
|
|
1412
|
+
if (!req.isMultipart()) {
|
|
1258
1413
|
reply.code(400);
|
|
1259
1414
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
|
1260
1415
|
}
|
|
1261
|
-
//
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
}
|
|
1276
|
-
chunks.push(chunk);
|
|
1277
|
-
}
|
|
1278
|
-
const body = Buffer.concat(chunks);
|
|
1279
|
-
// Extract image from multipart body
|
|
1280
|
-
const boundary = '--' + boundaryMatch[1];
|
|
1281
|
-
const boundaryBuf = Buffer.from(boundary);
|
|
1282
|
-
const parts = [];
|
|
1283
|
-
let pos = 0;
|
|
1284
|
-
while (pos < body.length) {
|
|
1285
|
-
const start = body.indexOf(boundaryBuf, pos);
|
|
1286
|
-
if (start === -1)
|
|
1287
|
-
break;
|
|
1288
|
-
const afterBoundary = start + boundaryBuf.length;
|
|
1289
|
-
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d)
|
|
1290
|
-
break;
|
|
1291
|
-
const headerStart = afterBoundary + 2;
|
|
1292
|
-
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
|
|
1293
|
-
if (headerEnd === -1)
|
|
1294
|
-
break;
|
|
1295
|
-
const headers = body.subarray(headerStart, headerEnd).toString();
|
|
1296
|
-
const dataStart = headerEnd + 4;
|
|
1297
|
-
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
|
|
1298
|
-
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
|
|
1299
|
-
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
|
|
1300
|
-
pos = nextBoundary === -1 ? body.length : nextBoundary;
|
|
1301
|
-
}
|
|
1302
|
-
const imagePart = parts.find((p) => p.headers.includes('name="image"'));
|
|
1303
|
-
if (!imagePart || imagePart.data.length === 0) {
|
|
1416
|
+
// Read the single file part. @fastify/multipart enforces the 10MB size cap
|
|
1417
|
+
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
|
|
1418
|
+
// boundary scanner with several bugs: literal boundary matches anywhere in
|
|
1419
|
+
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
|
|
1420
|
+
// offsets), no part-count cap.
|
|
1421
|
+
let part;
|
|
1422
|
+
try {
|
|
1423
|
+
part = await req.file();
|
|
1424
|
+
}
|
|
1425
|
+
catch (err) {
|
|
1426
|
+
reply.code(413);
|
|
1427
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
|
|
1428
|
+
}
|
|
1429
|
+
if (!part) {
|
|
1304
1430
|
reply.code(400);
|
|
1305
1431
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
|
|
1306
1432
|
}
|
|
1307
|
-
|
|
1433
|
+
if (part.fieldname !== 'image') {
|
|
1434
|
+
reply.code(400);
|
|
1435
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
|
1436
|
+
}
|
|
1437
|
+
let imageBytes;
|
|
1438
|
+
try {
|
|
1439
|
+
imageBytes = await part.toBuffer();
|
|
1440
|
+
}
|
|
1441
|
+
catch (err) {
|
|
1442
|
+
reply.code(413);
|
|
1443
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
|
|
1444
|
+
}
|
|
1445
|
+
if (imageBytes.length === 0) {
|
|
1446
|
+
reply.code(400);
|
|
1447
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
|
|
1448
|
+
}
|
|
1449
|
+
// Determine extension from filename or Content-Type.
|
|
1308
1450
|
let ext = '.png';
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
const origExt = extname(filenameMatch[1]).toLowerCase();
|
|
1451
|
+
if (part.filename) {
|
|
1452
|
+
const origExt = extname(part.filename).toLowerCase();
|
|
1312
1453
|
if (ALLOWED_IMAGE_EXTS.has(origExt))
|
|
1313
1454
|
ext = origExt;
|
|
1314
1455
|
}
|
|
1315
|
-
const
|
|
1316
|
-
if (
|
|
1456
|
+
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
|
|
1457
|
+
if (mimeMatch) {
|
|
1317
1458
|
const map = {
|
|
1318
1459
|
png: '.png',
|
|
1319
1460
|
jpeg: '.jpg',
|
|
@@ -1322,20 +1463,52 @@ export function registerSessionRoutes(app, ctx) {
|
|
|
1322
1463
|
gif: '.gif',
|
|
1323
1464
|
bmp: '.bmp',
|
|
1324
1465
|
};
|
|
1325
|
-
ext = map[
|
|
1466
|
+
ext = map[mimeMatch[1]] ?? ext;
|
|
1326
1467
|
}
|
|
1327
1468
|
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
|
|
1328
1469
|
reply.code(400);
|
|
1329
1470
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported image type: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTS].join(', ')}`);
|
|
1330
1471
|
}
|
|
1472
|
+
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
|
1473
|
+
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
|
1474
|
+
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
|
1475
|
+
reply.code(415);
|
|
1476
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
|
1477
|
+
}
|
|
1331
1478
|
// Save to {workingDir}/.claude-images/
|
|
1479
|
+
// Refuse symlinks at imageDir — an agent or postinstall script could plant
|
|
1480
|
+
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
|
|
1481
|
+
// We lstat (not stat) so we see the symlink itself. Use mkdir without
|
|
1482
|
+
// `recursive` so the leaf creation does not follow a symlink either, and
|
|
1483
|
+
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
|
|
1332
1484
|
const imageDir = join(session.workingDir, '.claude-images');
|
|
1333
|
-
|
|
1334
|
-
|
|
1485
|
+
try {
|
|
1486
|
+
const dirStat = await fs.lstat(imageDir);
|
|
1487
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
|
|
1488
|
+
reply.code(403);
|
|
1489
|
+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
|
|
1490
|
+
}
|
|
1335
1491
|
}
|
|
1336
|
-
|
|
1492
|
+
catch (err) {
|
|
1493
|
+
if (err.code !== 'ENOENT')
|
|
1494
|
+
throw err;
|
|
1495
|
+
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
|
|
1496
|
+
// the leaf. session.workingDir is guaranteed to exist (live session).
|
|
1497
|
+
await fs.mkdir(imageDir);
|
|
1498
|
+
}
|
|
1499
|
+
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
|
1500
|
+
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
|
1501
|
+
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
|
1337
1502
|
const filepath = join(imageDir, filename);
|
|
1338
|
-
|
|
1503
|
+
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
|
1504
|
+
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
|
1505
|
+
const fh = await fs.open(filepath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW);
|
|
1506
|
+
try {
|
|
1507
|
+
await fh.writeFile(imageBytes);
|
|
1508
|
+
}
|
|
1509
|
+
finally {
|
|
1510
|
+
await fh.close();
|
|
1511
|
+
}
|
|
1339
1512
|
return { success: true, path: filepath, filename };
|
|
1340
1513
|
});
|
|
1341
1514
|
}
|