@cjhyy/code-shell-server 0.8.0
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/README.md +77 -0
- package/dist/attachment-service.d.ts +80 -0
- package/dist/attachment-service.js +587 -0
- package/dist/bin/code-shell-serve.d.ts +2 -0
- package/dist/bin/code-shell-serve.js +7 -0
- package/dist/client-message-id.d.ts +1 -0
- package/dist/client-message-id.js +8 -0
- package/dist/image-byte-probe.d.ts +17 -0
- package/dist/image-byte-probe.js +330 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.mobile-remote.d.ts +21 -0
- package/dist/index.mobile-remote.js +21 -0
- package/dist/index.serve.d.ts +3 -0
- package/dist/index.serve.js +3 -0
- package/dist/index.storage.d.ts +10 -0
- package/dist/index.storage.js +10 -0
- package/dist/index.worker.d.ts +2 -0
- package/dist/index.worker.js +2 -0
- package/dist/mobile-remote/access-passcode.d.ts +80 -0
- package/dist/mobile-remote/access-passcode.js +354 -0
- package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
- package/dist/mobile-remote/cloudflared-binary.js +242 -0
- package/dist/mobile-remote/codex-parse.d.ts +34 -0
- package/dist/mobile-remote/codex-parse.js +131 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
- package/dist/mobile-remote/codex-room-agent.js +213 -0
- package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
- package/dist/mobile-remote/mobile-attachments.js +119 -0
- package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
- package/dist/mobile-remote/mobile-chat-turn.js +67 -0
- package/dist/mobile-remote/mobile-history.d.ts +22 -0
- package/dist/mobile-remote/mobile-history.js +27 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
- package/dist/mobile-remote/mobile-static.d.ts +43 -0
- package/dist/mobile-remote/mobile-static.js +154 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
- package/dist/mobile-remote/mobile-upload-service.js +370 -0
- package/dist/mobile-remote/pairing.d.ts +8 -0
- package/dist/mobile-remote/pairing.js +23 -0
- package/dist/mobile-remote/path-bins.d.ts +7 -0
- package/dist/mobile-remote/path-bins.js +16 -0
- package/dist/mobile-remote/pending-approvals.d.ts +14 -0
- package/dist/mobile-remote/pending-approvals.js +54 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
- package/dist/mobile-remote/remote-host-manager.js +345 -0
- package/dist/mobile-remote/resident-agent.d.ts +98 -0
- package/dist/mobile-remote/resident-agent.js +216 -0
- package/dist/mobile-remote/room-manager.d.ts +286 -0
- package/dist/mobile-remote/room-manager.js +765 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
- package/dist/mobile-remote/trusted-device-store.js +118 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
- package/dist/mobile-remote/tunnel-manager.js +375 -0
- package/dist/mobile-remote/types.d.ts +1 -0
- package/dist/mobile-remote/types.js +1 -0
- package/dist/mobile-remote/viewer-identity.d.ts +5 -0
- package/dist/mobile-remote/viewer-identity.js +5 -0
- package/dist/serve/cli.d.ts +15 -0
- package/dist/serve/cli.js +115 -0
- package/dist/serve/headless-server.d.ts +40 -0
- package/dist/serve/headless-server.js +276 -0
- package/dist/sessions-service.d.ts +73 -0
- package/dist/sessions-service.js +303 -0
- package/dist/static-files.d.ts +1 -0
- package/dist/static-files.js +25 -0
- package/dist/worker-bridge-core.d.ts +154 -0
- package/dist/worker-bridge-core.js +315 -0
- package/package.json +63 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFile, lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { basename, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { sanitizeImageBytes } from "./image-byte-probe.js";
|
|
5
|
+
const CODE_SHELL_DIR = ".code-shell";
|
|
6
|
+
const ATTACHMENTS_DIR = "attachments";
|
|
7
|
+
const MANIFEST_FILE = "manifest.jsonl";
|
|
8
|
+
const GITIGNORE_CONTENT = "*\n!.gitignore\n";
|
|
9
|
+
export const DRAFT_ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
export const SENT_ATTACHMENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
export const MAX_STAGED_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
12
|
+
export const MAX_STAGED_FILE_BYTES = 20 * 1024 * 1024;
|
|
13
|
+
const IMAGE_MIME_EXT = {
|
|
14
|
+
"image/png": ".png",
|
|
15
|
+
"image/jpeg": ".jpg",
|
|
16
|
+
"image/jpg": ".jpg",
|
|
17
|
+
"image/webp": ".webp",
|
|
18
|
+
"image/gif": ".gif",
|
|
19
|
+
};
|
|
20
|
+
const ALLOWED_IMAGE_MIMES = new Set(Object.keys(IMAGE_MIME_EXT));
|
|
21
|
+
let idCounter = 0;
|
|
22
|
+
export async function stageImageDataUrl(input) {
|
|
23
|
+
const parsed = parseDataUrl(input.dataUrl);
|
|
24
|
+
if (!parsed || !ALLOWED_IMAGE_MIMES.has(parsed.mime)) {
|
|
25
|
+
throw new Error(`unsupported image data URL MIME`);
|
|
26
|
+
}
|
|
27
|
+
if (input.mime && normalizeMime(input.mime) !== parsed.mime) {
|
|
28
|
+
throw new Error(`data URL MIME ${parsed.mime} does not match declared MIME ${input.mime}`);
|
|
29
|
+
}
|
|
30
|
+
return stageImageBuffer(input, parsed.mime, parsed.buffer);
|
|
31
|
+
}
|
|
32
|
+
/** Stage already-transferred bytes without expanding them back through base64. */
|
|
33
|
+
export async function stageImageBytes(input) {
|
|
34
|
+
const mime = normalizeMime(input.mime);
|
|
35
|
+
if (!ALLOWED_IMAGE_MIMES.has(mime))
|
|
36
|
+
throw new Error(`unsupported image MIME ${input.mime}`);
|
|
37
|
+
if ((input.bytes === undefined) === (input.sourceFile === undefined)) {
|
|
38
|
+
throw new Error("exactly one of bytes or sourceFile is required");
|
|
39
|
+
}
|
|
40
|
+
let buffer;
|
|
41
|
+
if (input.sourceFile !== undefined) {
|
|
42
|
+
const source = await lstat(input.sourceFile);
|
|
43
|
+
if (!source.isFile() || source.isSymbolicLink()) {
|
|
44
|
+
throw new Error("image source must be a regular file");
|
|
45
|
+
}
|
|
46
|
+
if (source.size > MAX_STAGED_IMAGE_BYTES) {
|
|
47
|
+
throw new Error(`image attachment exceeds size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
|
|
48
|
+
}
|
|
49
|
+
buffer = await readFile(input.sourceFile);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
buffer = Buffer.from(input.bytes);
|
|
53
|
+
}
|
|
54
|
+
return stageImageBuffer(input, mime, buffer);
|
|
55
|
+
}
|
|
56
|
+
async function stageImageBuffer(input, mime, buffer) {
|
|
57
|
+
const cwd = await resolveExistingDirectory(input.cwd, "cwd");
|
|
58
|
+
assertSafeSessionId(input.sessionId);
|
|
59
|
+
if (buffer.byteLength > MAX_STAGED_IMAGE_BYTES) {
|
|
60
|
+
throw new Error(`image attachment exceeds size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
|
|
61
|
+
}
|
|
62
|
+
const sanitized = sanitizeImageBytes(mime, buffer);
|
|
63
|
+
buffer = sanitized.bytes;
|
|
64
|
+
const image = sanitized.image;
|
|
65
|
+
const attachmentsRoot = await ensureAttachmentsRoot(cwd);
|
|
66
|
+
const sessionDir = await ensureSessionDir(attachmentsRoot, input.sessionId);
|
|
67
|
+
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
68
|
+
const sha16 = sha256.slice(0, 16);
|
|
69
|
+
const ext = IMAGE_MIME_EXT[mime] ?? ".bin";
|
|
70
|
+
const safeBase = safeSlug(input.name, "image");
|
|
71
|
+
const existing = await findExistingSessionFile(sessionDir, sha16);
|
|
72
|
+
const filename = existing ?? `${sha16}-${safeBase}${ext}`;
|
|
73
|
+
const absPath = await safeJoin(sessionDir, filename, sessionDir);
|
|
74
|
+
if (!existing) {
|
|
75
|
+
const tmpPath = await safeJoin(sessionDir, `.${filename}.${process.pid}.${Date.now()}.tmp`, sessionDir);
|
|
76
|
+
await writeFile(tmpPath, buffer, { flag: "wx" });
|
|
77
|
+
try {
|
|
78
|
+
await rename(tmpPath, absPath);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const relPath = normalizePath(relative(cwd, absPath));
|
|
86
|
+
const createdAt = Date.now();
|
|
87
|
+
idCounter += 1;
|
|
88
|
+
const meta = {
|
|
89
|
+
id: `att_${sha16}_${idCounter.toString(36)}`,
|
|
90
|
+
sessionId: input.sessionId,
|
|
91
|
+
kind: "image",
|
|
92
|
+
origin: input.origin,
|
|
93
|
+
path: relPath,
|
|
94
|
+
absPath,
|
|
95
|
+
relPath,
|
|
96
|
+
mime,
|
|
97
|
+
size: buffer.byteLength,
|
|
98
|
+
sha256,
|
|
99
|
+
...(input.name ? { originalName: input.name } : {}),
|
|
100
|
+
createdAt,
|
|
101
|
+
width: image.width,
|
|
102
|
+
height: image.height,
|
|
103
|
+
vision: { include: true },
|
|
104
|
+
};
|
|
105
|
+
await appendManifest(sessionDir, { event: "staged", status: "draft", ...meta });
|
|
106
|
+
return meta;
|
|
107
|
+
}
|
|
108
|
+
/** Stage an untrusted non-image file inside the canonical attachment root. */
|
|
109
|
+
export async function stageFileBytes(input) {
|
|
110
|
+
const cwd = await resolveExistingDirectory(input.cwd, "cwd");
|
|
111
|
+
assertSafeSessionId(input.sessionId);
|
|
112
|
+
const buffer = Buffer.from(input.bytes);
|
|
113
|
+
if (buffer.byteLength > MAX_STAGED_FILE_BYTES) {
|
|
114
|
+
throw new Error(`file attachment exceeds size limit (${formatBytes(MAX_STAGED_FILE_BYTES)})`);
|
|
115
|
+
}
|
|
116
|
+
const attachmentsRoot = await ensureAttachmentsRoot(cwd);
|
|
117
|
+
const sessionDir = await ensureSessionDir(attachmentsRoot, input.sessionId);
|
|
118
|
+
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
119
|
+
const sha16 = sha256.slice(0, 16);
|
|
120
|
+
const safeBase = safeSlug(input.name, "file");
|
|
121
|
+
const rawExt = extname(basename(input.name ?? ""));
|
|
122
|
+
const originalExt = /^\.[A-Za-z0-9]{1,15}$/.test(rawExt) ? rawExt : "";
|
|
123
|
+
const existing = await findExistingSessionFile(sessionDir, sha16);
|
|
124
|
+
const filename = existing ?? `${sha16}-${safeBase}${originalExt}`;
|
|
125
|
+
const absPath = await safeJoin(sessionDir, filename, sessionDir);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
const tmpPath = await safeJoin(sessionDir, `.${filename}.${process.pid}.${Date.now()}.tmp`, sessionDir);
|
|
128
|
+
await writeFile(tmpPath, buffer, { flag: "wx" });
|
|
129
|
+
try {
|
|
130
|
+
await rename(tmpPath, absPath);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
await rm(tmpPath, { force: true }).catch(() => undefined);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const relPath = normalizePath(relative(cwd, absPath));
|
|
138
|
+
const createdAt = Date.now();
|
|
139
|
+
idCounter += 1;
|
|
140
|
+
const meta = {
|
|
141
|
+
id: `att_${sha16}_${idCounter.toString(36)}`,
|
|
142
|
+
sessionId: input.sessionId,
|
|
143
|
+
kind: "file",
|
|
144
|
+
origin: input.origin,
|
|
145
|
+
path: relPath,
|
|
146
|
+
absPath,
|
|
147
|
+
relPath,
|
|
148
|
+
mime: normalizeMime(input.mime) || "application/octet-stream",
|
|
149
|
+
size: buffer.byteLength,
|
|
150
|
+
sha256,
|
|
151
|
+
...(input.name ? { originalName: basename(input.name) } : {}),
|
|
152
|
+
createdAt,
|
|
153
|
+
};
|
|
154
|
+
await appendManifest(sessionDir, { event: "staged", status: "draft", ...meta });
|
|
155
|
+
return meta;
|
|
156
|
+
}
|
|
157
|
+
export async function markAttachmentsSent(cwd, sessionId, attachments) {
|
|
158
|
+
if (attachments.length === 0)
|
|
159
|
+
return;
|
|
160
|
+
const resolvedCwd = await resolveExistingDirectory(cwd, "cwd");
|
|
161
|
+
assertSafeSessionId(sessionId);
|
|
162
|
+
const sessionDir = await ensureSessionDir(await ensureAttachmentsRoot(resolvedCwd), sessionId);
|
|
163
|
+
const sentAt = Date.now();
|
|
164
|
+
for (const attachment of attachments) {
|
|
165
|
+
if (attachment.sessionId !== sessionId)
|
|
166
|
+
continue;
|
|
167
|
+
await appendManifest(sessionDir, {
|
|
168
|
+
event: "sent",
|
|
169
|
+
id: attachment.id,
|
|
170
|
+
sessionId,
|
|
171
|
+
kind: attachment.kind,
|
|
172
|
+
origin: attachment.origin,
|
|
173
|
+
path: attachment.path,
|
|
174
|
+
absPath: attachment.absPath,
|
|
175
|
+
relPath: attachment.relPath,
|
|
176
|
+
mime: attachment.mime,
|
|
177
|
+
size: attachment.size,
|
|
178
|
+
sha256: attachment.sha256,
|
|
179
|
+
originalName: attachment.originalName,
|
|
180
|
+
sentAt,
|
|
181
|
+
status: "sent",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export async function listRecentAttachments(input) {
|
|
186
|
+
const cwd = await resolveExistingDirectory(input.cwd, "cwd");
|
|
187
|
+
const root = await resolveAttachmentsRoot(cwd);
|
|
188
|
+
if (!root)
|
|
189
|
+
return [];
|
|
190
|
+
const sessionIds = input.sessionId ? [input.sessionId] : await listSafeSessionDirs(root);
|
|
191
|
+
const staged = new Map();
|
|
192
|
+
const sent = new Set();
|
|
193
|
+
for (const sessionId of sessionIds) {
|
|
194
|
+
assertSafeSessionId(sessionId);
|
|
195
|
+
const sessionDir = await safeJoin(root, sessionId, root).catch(() => null);
|
|
196
|
+
if (!sessionDir)
|
|
197
|
+
continue;
|
|
198
|
+
const records = await readManifest(sessionDir);
|
|
199
|
+
for (const record of records) {
|
|
200
|
+
if (record.event === "staged") {
|
|
201
|
+
const meta = recordToMeta(record);
|
|
202
|
+
if (meta && (await pathExistsInside(meta.absPath, root))) {
|
|
203
|
+
staged.set(meta.id, meta);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (record.event === "sent" && record.id) {
|
|
207
|
+
sent.add(record.id);
|
|
208
|
+
}
|
|
209
|
+
else if ((record.event === "removedFromDraft" || record.event === "cleanup") && record.id) {
|
|
210
|
+
staged.delete(record.id);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return [...staged.values()]
|
|
215
|
+
.filter((meta) => sent.has(meta.id) || meta.kind !== "image")
|
|
216
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
217
|
+
.slice(0, 50);
|
|
218
|
+
}
|
|
219
|
+
export async function cleanupAttachments(input) {
|
|
220
|
+
const cwd = await resolveExistingDirectory(input.cwd, "cwd");
|
|
221
|
+
const root = await resolveAttachmentsRoot(cwd);
|
|
222
|
+
if (!root)
|
|
223
|
+
return { removed: [], sessionsRemoved: [] };
|
|
224
|
+
const now = input.now ?? Date.now();
|
|
225
|
+
const sessionIds = input.sessionId ? [input.sessionId] : await listSafeSessionDirs(root);
|
|
226
|
+
const removed = [];
|
|
227
|
+
const sessionsRemoved = [];
|
|
228
|
+
for (const sessionId of sessionIds) {
|
|
229
|
+
assertSafeSessionId(sessionId);
|
|
230
|
+
const sessionDir = await safeJoin(root, sessionId, root).catch(() => null);
|
|
231
|
+
if (!sessionDir)
|
|
232
|
+
continue;
|
|
233
|
+
const records = await readManifest(sessionDir);
|
|
234
|
+
if (input.sessionId) {
|
|
235
|
+
const files = await manifestFilesInside(records, root);
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
await rm(file, { force: true }).catch(() => undefined);
|
|
238
|
+
removed.push(file);
|
|
239
|
+
}
|
|
240
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
241
|
+
sessionsRemoved.push(sessionId);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const latest = latestAttachmentState(records);
|
|
245
|
+
for (const state of latest.values()) {
|
|
246
|
+
const createdAt = state.createdAt ?? 0;
|
|
247
|
+
const sentAt = state.sentAt;
|
|
248
|
+
const isSent = typeof sentAt === "number";
|
|
249
|
+
const ttl = isSent ? SENT_ATTACHMENT_TTL_MS : DRAFT_ATTACHMENT_TTL_MS;
|
|
250
|
+
const basis = isSent ? sentAt : createdAt;
|
|
251
|
+
if (!basis || now - basis < ttl)
|
|
252
|
+
continue;
|
|
253
|
+
if (!state.absPath || !(await pathExistsInside(state.absPath, root)))
|
|
254
|
+
continue;
|
|
255
|
+
await rm(state.absPath, { force: true }).catch(() => undefined);
|
|
256
|
+
removed.push(state.absPath);
|
|
257
|
+
await appendManifest(sessionDir, {
|
|
258
|
+
event: "cleanup",
|
|
259
|
+
id: state.id,
|
|
260
|
+
sessionId,
|
|
261
|
+
absPath: state.absPath,
|
|
262
|
+
relPath: state.relPath,
|
|
263
|
+
path: state.path,
|
|
264
|
+
sha256: state.sha256,
|
|
265
|
+
cleanedAt: now,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { removed, sessionsRemoved };
|
|
270
|
+
}
|
|
271
|
+
export async function cleanupSessionAttachments(cwd, sessionId) {
|
|
272
|
+
await cleanupAttachments({ cwd, sessionId }).catch(() => undefined);
|
|
273
|
+
}
|
|
274
|
+
function normalizeMime(mime) {
|
|
275
|
+
return String(mime ?? "")
|
|
276
|
+
.trim()
|
|
277
|
+
.toLowerCase();
|
|
278
|
+
}
|
|
279
|
+
function parseDataUrl(dataUrl) {
|
|
280
|
+
if (typeof dataUrl !== "string")
|
|
281
|
+
return null;
|
|
282
|
+
const match = /^data:([^;,]+);base64,([\s\S]*)$/.exec(dataUrl);
|
|
283
|
+
if (!match)
|
|
284
|
+
return null;
|
|
285
|
+
const mime = normalizeMime(match[1]);
|
|
286
|
+
const base64 = (match[2] ?? "").replace(/\s+/g, "");
|
|
287
|
+
const decodedSize = byteLengthFromBase64(base64);
|
|
288
|
+
if (decodedSize > MAX_STAGED_IMAGE_BYTES) {
|
|
289
|
+
throw new Error(`image attachment exceeds decoded size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const buffer = Buffer.from(base64, "base64");
|
|
293
|
+
if (buffer.byteLength > MAX_STAGED_IMAGE_BYTES) {
|
|
294
|
+
throw new Error(`image attachment exceeds decoded size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
|
|
295
|
+
}
|
|
296
|
+
return { mime, buffer };
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (String(error.message).includes("decoded size limit"))
|
|
300
|
+
throw error;
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function byteLengthFromBase64(base64) {
|
|
305
|
+
if (!base64)
|
|
306
|
+
return 0;
|
|
307
|
+
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
|
308
|
+
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
309
|
+
}
|
|
310
|
+
function formatBytes(bytes) {
|
|
311
|
+
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
|
312
|
+
}
|
|
313
|
+
async function resolveExistingDirectory(path, label) {
|
|
314
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
315
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
316
|
+
}
|
|
317
|
+
const resolved = resolve(path);
|
|
318
|
+
const info = await stat(resolved);
|
|
319
|
+
if (!info.isDirectory())
|
|
320
|
+
throw new Error(`${label} must be a directory`);
|
|
321
|
+
return realpath(resolved);
|
|
322
|
+
}
|
|
323
|
+
export function assertSafeSessionId(sessionId) {
|
|
324
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
325
|
+
throw new Error(`invalid session id: must be a non-empty string`);
|
|
326
|
+
}
|
|
327
|
+
if (sessionId.includes("/") || sessionId.includes("\\")) {
|
|
328
|
+
throw new Error(`invalid session id: contains path separator`);
|
|
329
|
+
}
|
|
330
|
+
if (sessionId === "." || sessionId === ".." || sessionId.includes("..")) {
|
|
331
|
+
throw new Error(`invalid session id: contains parent-dir token`);
|
|
332
|
+
}
|
|
333
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(sessionId)) {
|
|
334
|
+
throw new Error(`invalid session id: unexpected characters`);
|
|
335
|
+
}
|
|
336
|
+
if (sessionId.length > 128) {
|
|
337
|
+
throw new Error(`invalid session id: too long`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async function ensureAttachmentsRoot(cwd) {
|
|
341
|
+
const codeShellDir = await safeJoin(cwd, CODE_SHELL_DIR, cwd);
|
|
342
|
+
await rejectSymlinkIfExists(codeShellDir, "CodeShell state directory");
|
|
343
|
+
await mkdir(codeShellDir, { recursive: true });
|
|
344
|
+
const codeShellReal = await realDirectoryInside(codeShellDir, cwd, {
|
|
345
|
+
label: "CodeShell state directory",
|
|
346
|
+
allowSame: false,
|
|
347
|
+
});
|
|
348
|
+
const gitignore = await safeJoin(codeShellReal, ".gitignore", codeShellReal);
|
|
349
|
+
await writeFile(gitignore, GITIGNORE_CONTENT, "utf-8");
|
|
350
|
+
const attachmentsRoot = await safeJoin(codeShellReal, ATTACHMENTS_DIR, codeShellReal);
|
|
351
|
+
await rejectSymlinkIfExists(attachmentsRoot, "attachments root");
|
|
352
|
+
await mkdir(attachmentsRoot, { recursive: true });
|
|
353
|
+
return realDirectoryInside(attachmentsRoot, codeShellReal, {
|
|
354
|
+
label: "attachments root",
|
|
355
|
+
allowSame: false,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
async function resolveAttachmentsRoot(cwd) {
|
|
359
|
+
const root = join(cwd, CODE_SHELL_DIR, ATTACHMENTS_DIR);
|
|
360
|
+
try {
|
|
361
|
+
const info = await lstat(root);
|
|
362
|
+
if (info.isSymbolicLink())
|
|
363
|
+
throw new Error(`attachments root must not be a symlink`);
|
|
364
|
+
if (!info.isDirectory())
|
|
365
|
+
return null;
|
|
366
|
+
return realDirectoryInside(root, join(cwd, CODE_SHELL_DIR), {
|
|
367
|
+
label: "attachments root",
|
|
368
|
+
allowSame: false,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function ensureSessionDir(attachmentsRoot, sessionId) {
|
|
376
|
+
const sessionDir = await safeJoin(attachmentsRoot, sessionId, attachmentsRoot);
|
|
377
|
+
await rejectSymlinkIfExists(sessionDir, "attachment session directory");
|
|
378
|
+
await mkdir(sessionDir, { recursive: true });
|
|
379
|
+
return realDirectoryInside(sessionDir, attachmentsRoot, {
|
|
380
|
+
label: "attachment session directory",
|
|
381
|
+
allowSame: false,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async function safeJoin(root, child, containmentRoot) {
|
|
385
|
+
const parent = await realpath(root);
|
|
386
|
+
const normalizedContainment = await realpath(containmentRoot);
|
|
387
|
+
const target = resolve(parent, child);
|
|
388
|
+
assertInside(target, normalizedContainment, {
|
|
389
|
+
label: "attachment path",
|
|
390
|
+
allowSame: false,
|
|
391
|
+
});
|
|
392
|
+
assertInside(target, parent, {
|
|
393
|
+
label: "attachment path",
|
|
394
|
+
allowSame: false,
|
|
395
|
+
});
|
|
396
|
+
try {
|
|
397
|
+
const info = await lstat(target);
|
|
398
|
+
if (info.isSymbolicLink())
|
|
399
|
+
throw new Error(`attachment path must not be a symlink`);
|
|
400
|
+
const targetReal = await realpath(target);
|
|
401
|
+
assertInside(targetReal, normalizedContainment, {
|
|
402
|
+
label: "attachment path",
|
|
403
|
+
allowSame: false,
|
|
404
|
+
});
|
|
405
|
+
assertInside(targetReal, parent, {
|
|
406
|
+
label: "attachment path",
|
|
407
|
+
allowSame: false,
|
|
408
|
+
});
|
|
409
|
+
return targetReal;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (error.code === "ENOENT")
|
|
413
|
+
return target;
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async function rejectSymlinkIfExists(path, label) {
|
|
418
|
+
try {
|
|
419
|
+
const info = await lstat(path);
|
|
420
|
+
if (info.isSymbolicLink())
|
|
421
|
+
throw new Error(`${label} must not be a symlink`);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (error.code === "ENOENT")
|
|
425
|
+
return;
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function realDirectoryInside(path, containmentRoot, opts) {
|
|
430
|
+
const info = await lstat(path);
|
|
431
|
+
if (info.isSymbolicLink())
|
|
432
|
+
throw new Error(`${opts.label} must not be a symlink`);
|
|
433
|
+
if (!info.isDirectory())
|
|
434
|
+
throw new Error(`${opts.label} must be a directory`);
|
|
435
|
+
const real = await realpath(path);
|
|
436
|
+
const rootReal = await realpath(containmentRoot);
|
|
437
|
+
assertInside(real, rootReal, opts);
|
|
438
|
+
return real;
|
|
439
|
+
}
|
|
440
|
+
function assertInside(target, containmentRoot, opts) {
|
|
441
|
+
const rel = relative(containmentRoot, target);
|
|
442
|
+
if (rel === ".." ||
|
|
443
|
+
rel.startsWith(`..${sep}`) ||
|
|
444
|
+
(!opts.allowSame && resolve(target) === resolve(containmentRoot))) {
|
|
445
|
+
throw new Error(`path escapes attachment root`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function pathExistsInside(absPath, root) {
|
|
449
|
+
try {
|
|
450
|
+
const info = await lstat(absPath);
|
|
451
|
+
if (info.isSymbolicLink())
|
|
452
|
+
return false;
|
|
453
|
+
const fileReal = await realpath(absPath);
|
|
454
|
+
const rootReal = await realpath(root);
|
|
455
|
+
const rel = relative(rootReal, fileReal);
|
|
456
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function safeSlug(name, fallback) {
|
|
463
|
+
const base = basename(String(name ?? fallback));
|
|
464
|
+
const noExt = base.slice(0, base.length - extname(base).length) || fallback;
|
|
465
|
+
const slug = noExt
|
|
466
|
+
.normalize("NFKC")
|
|
467
|
+
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, "-")
|
|
468
|
+
.replace(/\s+/g, "-")
|
|
469
|
+
.replace(/^\.+|\.+$/g, "")
|
|
470
|
+
.replace(/-+/g, "-")
|
|
471
|
+
.slice(0, 80)
|
|
472
|
+
.replace(/[-. ]+$/g, "");
|
|
473
|
+
return slug || fallback;
|
|
474
|
+
}
|
|
475
|
+
async function findExistingSessionFile(sessionDir, sha16) {
|
|
476
|
+
let entries;
|
|
477
|
+
try {
|
|
478
|
+
entries = await readdir(sessionDir);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
return entries.find((entry) => entry.startsWith(`${sha16}-`) && entry !== MANIFEST_FILE) ?? null;
|
|
484
|
+
}
|
|
485
|
+
async function appendManifest(sessionDir, record) {
|
|
486
|
+
const manifest = await safeJoin(sessionDir, MANIFEST_FILE, sessionDir);
|
|
487
|
+
await appendFile(manifest, `${JSON.stringify(record)}\n`, "utf-8");
|
|
488
|
+
}
|
|
489
|
+
async function readManifest(sessionDir) {
|
|
490
|
+
try {
|
|
491
|
+
const manifest = await safeJoin(sessionDir, MANIFEST_FILE, sessionDir);
|
|
492
|
+
const raw = await readFile(manifest, "utf-8");
|
|
493
|
+
return raw
|
|
494
|
+
.split("\n")
|
|
495
|
+
.filter(Boolean)
|
|
496
|
+
.map((line) => {
|
|
497
|
+
try {
|
|
498
|
+
return JSON.parse(line);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
.filter((record) => !!record);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async function listSafeSessionDirs(root) {
|
|
511
|
+
let entries;
|
|
512
|
+
try {
|
|
513
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
return entries
|
|
519
|
+
.filter((entry) => entry.isDirectory())
|
|
520
|
+
.map((entry) => entry.name)
|
|
521
|
+
.filter((name) => {
|
|
522
|
+
try {
|
|
523
|
+
assertSafeSessionId(name);
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
function normalizePath(p) {
|
|
532
|
+
return sep === "\\" ? p.replace(/\\/g, "/") : p;
|
|
533
|
+
}
|
|
534
|
+
function recordToMeta(record) {
|
|
535
|
+
if (!record.id ||
|
|
536
|
+
!record.sessionId ||
|
|
537
|
+
!record.kind ||
|
|
538
|
+
!record.origin ||
|
|
539
|
+
!record.path ||
|
|
540
|
+
!record.absPath ||
|
|
541
|
+
typeof record.size !== "number" ||
|
|
542
|
+
!record.sha256 ||
|
|
543
|
+
typeof record.createdAt !== "number") {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
return {
|
|
547
|
+
id: record.id,
|
|
548
|
+
sessionId: record.sessionId,
|
|
549
|
+
kind: record.kind,
|
|
550
|
+
origin: record.origin,
|
|
551
|
+
path: record.path,
|
|
552
|
+
absPath: record.absPath,
|
|
553
|
+
relPath: record.relPath,
|
|
554
|
+
mime: record.mime,
|
|
555
|
+
size: record.size,
|
|
556
|
+
sha256: record.sha256,
|
|
557
|
+
originalName: record.originalName,
|
|
558
|
+
createdAt: record.createdAt,
|
|
559
|
+
vision: record.kind === "image" ? { include: true } : undefined,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async function manifestFilesInside(records, root) {
|
|
563
|
+
const files = new Set();
|
|
564
|
+
for (const record of records) {
|
|
565
|
+
if (!record.absPath)
|
|
566
|
+
continue;
|
|
567
|
+
if (await pathExistsInside(record.absPath, root))
|
|
568
|
+
files.add(record.absPath);
|
|
569
|
+
}
|
|
570
|
+
return [...files];
|
|
571
|
+
}
|
|
572
|
+
function latestAttachmentState(records) {
|
|
573
|
+
const latest = new Map();
|
|
574
|
+
for (const record of records) {
|
|
575
|
+
if (!record.id)
|
|
576
|
+
continue;
|
|
577
|
+
if (record.event === "staged")
|
|
578
|
+
latest.set(record.id, record);
|
|
579
|
+
if (record.event === "sent") {
|
|
580
|
+
const prev = latest.get(record.id) ?? record;
|
|
581
|
+
latest.set(record.id, { ...prev, ...record, status: "sent" });
|
|
582
|
+
}
|
|
583
|
+
if (record.event === "removedFromDraft" || record.event === "cleanup")
|
|
584
|
+
latest.delete(record.id);
|
|
585
|
+
}
|
|
586
|
+
return latest;
|
|
587
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Headless no-account web host CLI. See serve/cli.ts.
|
|
3
|
+
import { runServeCli } from "../serve/cli.js";
|
|
4
|
+
runServeCli().catch((error) => {
|
|
5
|
+
console.error(`[serve] fatal: ${error instanceof Error ? error.message : String(error)}`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stablePromptHash(text: string): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ProbedImage {
|
|
2
|
+
mime: "image/png" | "image/jpeg" | "image/gif" | "image/webp";
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SanitizedImageBytes {
|
|
7
|
+
image: ProbedImage;
|
|
8
|
+
bytes: Buffer;
|
|
9
|
+
trailingBytes: number;
|
|
10
|
+
}
|
|
11
|
+
/** Strictly verify byte signature, bounded container structure, and declared MIME. */
|
|
12
|
+
export declare function probeImageBytes(declaredMime: string, bytes: Uint8Array): ProbedImage;
|
|
13
|
+
/**
|
|
14
|
+
* Verify untrusted image bytes and remove data after a complete JPEG EOI marker.
|
|
15
|
+
* Other image formats remain strict, and the returned JPEG is probed again after truncation.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sanitizeImageBytes(declaredMime: string, bytes: Uint8Array): SanitizedImageBytes;
|