@nordbyte/nordrelay 0.2.1
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/.env.example +88 -0
- package/Dockerfile +19 -0
- package/LICENSE +21 -0
- package/README.md +749 -0
- package/dist/access-control.js +146 -0
- package/dist/agent-factory.js +22 -0
- package/dist/agent.js +57 -0
- package/dist/artifacts.js +515 -0
- package/dist/attachments.js +69 -0
- package/dist/bot-preferences.js +146 -0
- package/dist/bot-ui.js +161 -0
- package/dist/bot.js +4520 -0
- package/dist/codex-auth.js +150 -0
- package/dist/codex-cli.js +79 -0
- package/dist/codex-config.js +50 -0
- package/dist/codex-launch.js +109 -0
- package/dist/codex-session.js +591 -0
- package/dist/codex-state.js +573 -0
- package/dist/config.js +385 -0
- package/dist/context-key.js +23 -0
- package/dist/error-messages.js +73 -0
- package/dist/format.js +121 -0
- package/dist/index.js +140 -0
- package/dist/logger.js +27 -0
- package/dist/operations.js +133 -0
- package/dist/persistence.js +65 -0
- package/dist/pi-cli.js +19 -0
- package/dist/pi-rpc.js +158 -0
- package/dist/pi-session.js +573 -0
- package/dist/pi-state.js +226 -0
- package/dist/prompt-store.js +241 -0
- package/dist/redaction.js +47 -0
- package/dist/session-format.js +191 -0
- package/dist/session-registry.js +195 -0
- package/dist/telegram-rate-limit.js +136 -0
- package/dist/voice.js +373 -0
- package/dist/workspace-policy.js +41 -0
- package/docker-compose.yml +17 -0
- package/launchd/start.sh +8 -0
- package/package.json +69 -0
- package/plugins/nordrelay/.codex-plugin/plugin.json +48 -0
- package/plugins/nordrelay/assets/nordrelay.svg +5 -0
- package/plugins/nordrelay/commands/remote.md +33 -0
- package/plugins/nordrelay/scripts/nordrelay.mjs +396 -0
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +26 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024;
|
|
6
|
+
const DEFAULT_RETENTION_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const DEFAULT_MAX_TURN_DIRS = 30;
|
|
8
|
+
const DEFAULT_MAX_INBOX_DIRS = 30;
|
|
9
|
+
const MAX_ARTIFACT_DEPTH = 8;
|
|
10
|
+
const IGNORED_PATTERNS = [/^\./, /^__pycache__$/, /\.tmp$/i, /~$/];
|
|
11
|
+
const WORKSPACE_ARTIFACT_IGNORED_DIRS = new Set([
|
|
12
|
+
".git",
|
|
13
|
+
".nordrelay",
|
|
14
|
+
".cache",
|
|
15
|
+
".next",
|
|
16
|
+
".pytest_cache",
|
|
17
|
+
".turbo",
|
|
18
|
+
".venv",
|
|
19
|
+
".vite",
|
|
20
|
+
"node_modules",
|
|
21
|
+
"dist",
|
|
22
|
+
"build",
|
|
23
|
+
"coverage",
|
|
24
|
+
"target",
|
|
25
|
+
"tmp",
|
|
26
|
+
"temp",
|
|
27
|
+
]);
|
|
28
|
+
export async function ensureOutDir(outDir) {
|
|
29
|
+
await mkdir(outDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
export async function collectArtifacts(outDir, maxFileSize) {
|
|
32
|
+
return (await collectArtifactReport(outDir, maxFileSize)).artifacts;
|
|
33
|
+
}
|
|
34
|
+
export async function collectArtifactReport(outDir, maxFileSize) {
|
|
35
|
+
if (!existsSync(outDir)) {
|
|
36
|
+
return { artifacts: [], skippedCount: 0 };
|
|
37
|
+
}
|
|
38
|
+
const maxSize = maxFileSize ?? MAX_TELEGRAM_FILE_SIZE;
|
|
39
|
+
const report = await collectArtifactReportFromDir(outDir, outDir, maxSize, 0);
|
|
40
|
+
report.artifacts.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
41
|
+
return report;
|
|
42
|
+
}
|
|
43
|
+
export async function collectRecentWorkspaceArtifacts(workspace, options) {
|
|
44
|
+
if (!existsSync(workspace)) {
|
|
45
|
+
return { artifacts: [], skippedCount: 0 };
|
|
46
|
+
}
|
|
47
|
+
const report = await collectRecentWorkspaceArtifactsFromDir(workspace, workspace, options.since.getTime(), options.until?.getTime() ?? Date.now() + 1000, options.maxFileSize ?? MAX_TELEGRAM_FILE_SIZE, new Set([...(options.ignoreDirs ?? [])]), options.ignoreGlobs ?? [], 0);
|
|
48
|
+
report.artifacts.sort((left, right) => {
|
|
49
|
+
const timeDelta = (right.modifiedAtMs ?? 0) - (left.modifiedAtMs ?? 0);
|
|
50
|
+
return timeDelta !== 0 ? timeDelta : left.relativePath.localeCompare(right.relativePath);
|
|
51
|
+
});
|
|
52
|
+
const limit = options.limit ?? 5;
|
|
53
|
+
return {
|
|
54
|
+
artifacts: report.artifacts.slice(0, limit),
|
|
55
|
+
skippedCount: report.skippedCount,
|
|
56
|
+
omittedCount: Math.max(0, report.artifacts.length - limit),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export async function persistWorkspaceArtifactReport(workspace, turnId, report) {
|
|
60
|
+
const safeTurnId = sanitizeTurnId(turnId);
|
|
61
|
+
if (!safeTurnId || (report.artifacts.length === 0 && report.skippedCount === 0 && !report.omittedCount)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const turnDir = artifactTurnDir(workspace, safeTurnId);
|
|
65
|
+
await mkdir(turnDir, { recursive: true });
|
|
66
|
+
const manifest = {
|
|
67
|
+
version: 1,
|
|
68
|
+
source: "workspace",
|
|
69
|
+
turnId: safeTurnId,
|
|
70
|
+
outDir: workspace,
|
|
71
|
+
updatedAt: new Date().toISOString(),
|
|
72
|
+
skippedCount: report.skippedCount,
|
|
73
|
+
omittedCount: report.omittedCount,
|
|
74
|
+
artifacts: report.artifacts,
|
|
75
|
+
};
|
|
76
|
+
await writeFile(artifactManifestPath(workspace, safeTurnId), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
77
|
+
return {
|
|
78
|
+
turnId: safeTurnId,
|
|
79
|
+
outDir: workspace,
|
|
80
|
+
updatedAt: new Date(manifest.updatedAt),
|
|
81
|
+
artifacts: report.artifacts,
|
|
82
|
+
skippedCount: report.skippedCount,
|
|
83
|
+
omittedCount: report.omittedCount,
|
|
84
|
+
totalSizeBytes: totalArtifactSize(report.artifacts),
|
|
85
|
+
source: "workspace",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export async function listRecentArtifactReports(workspace, limit = 5, maxFileSize) {
|
|
89
|
+
const turnsDir = artifactTurnsDir(workspace);
|
|
90
|
+
const entries = await readdir(turnsDir, { withFileTypes: true }).catch(() => []);
|
|
91
|
+
const reports = [];
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
if (!entry.isDirectory() || shouldIgnoreEntry(entry.name)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const manifestReport = await readWorkspaceArtifactManifest(workspace, entry.name, maxFileSize);
|
|
97
|
+
if (manifestReport) {
|
|
98
|
+
reports.push(manifestReport);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const outDir = path.join(turnsDir, entry.name, "out");
|
|
102
|
+
const fileStat = await stat(outDir).catch(() => null);
|
|
103
|
+
if (!fileStat?.isDirectory()) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const report = await collectArtifactReport(outDir, maxFileSize);
|
|
107
|
+
if (report.artifacts.length === 0 && report.skippedCount === 0) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
reports.push({
|
|
111
|
+
turnId: entry.name,
|
|
112
|
+
outDir,
|
|
113
|
+
updatedAt: fileStat.mtime,
|
|
114
|
+
artifacts: report.artifacts,
|
|
115
|
+
skippedCount: report.skippedCount,
|
|
116
|
+
omittedCount: report.omittedCount,
|
|
117
|
+
totalSizeBytes: totalArtifactSize(report.artifacts),
|
|
118
|
+
source: "turn",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
reports.sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime());
|
|
122
|
+
return reports.slice(0, Math.max(0, limit));
|
|
123
|
+
}
|
|
124
|
+
export async function getArtifactTurnReport(workspace, turnId, maxFileSize) {
|
|
125
|
+
const safeTurnId = sanitizeTurnId(turnId);
|
|
126
|
+
if (!safeTurnId) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const manifestReport = await readWorkspaceArtifactManifest(workspace, safeTurnId, maxFileSize);
|
|
130
|
+
if (manifestReport) {
|
|
131
|
+
return manifestReport;
|
|
132
|
+
}
|
|
133
|
+
const outDir = artifactOutDirForTurn(workspace, safeTurnId);
|
|
134
|
+
const fileStat = await stat(outDir).catch(() => null);
|
|
135
|
+
if (!fileStat?.isDirectory()) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const report = await collectArtifactReport(outDir, maxFileSize);
|
|
139
|
+
if (report.artifacts.length === 0 && report.skippedCount === 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
turnId: safeTurnId,
|
|
144
|
+
outDir,
|
|
145
|
+
updatedAt: fileStat.mtime,
|
|
146
|
+
artifacts: report.artifacts,
|
|
147
|
+
skippedCount: report.skippedCount,
|
|
148
|
+
omittedCount: report.omittedCount,
|
|
149
|
+
totalSizeBytes: totalArtifactSize(report.artifacts),
|
|
150
|
+
source: "turn",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export async function removeArtifactTurn(workspace, turnId) {
|
|
154
|
+
const safeTurnId = sanitizeTurnId(turnId);
|
|
155
|
+
if (!safeTurnId) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const turnDir = path.join(artifactTurnsDir(workspace), safeTurnId);
|
|
159
|
+
const fileStat = await stat(turnDir).catch(() => null);
|
|
160
|
+
if (!fileStat?.isDirectory()) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
await rm(turnDir, { recursive: true, force: true });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
export function artifactOutDirForTurn(workspace, turnId) {
|
|
167
|
+
return path.join(artifactTurnsDir(workspace), sanitizeTurnId(turnId) ?? "", "out");
|
|
168
|
+
}
|
|
169
|
+
export async function createArtifactZipBundle(artifacts, outDir, options = {}) {
|
|
170
|
+
if (artifacts.length === 0) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const sourcePaths = artifacts
|
|
174
|
+
.map((artifact) => artifact.relativePath)
|
|
175
|
+
.filter((relativePath) => relativePath && !relativePath.includes("\n"));
|
|
176
|
+
if (sourcePaths.length !== artifacts.length) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const bundleDir = path.join(outDir, ".telegram-artifacts");
|
|
180
|
+
await mkdir(bundleDir, { recursive: true });
|
|
181
|
+
const bundleName = options.bundleName ?? `codex-artifacts-${sanitizeZipStem(path.basename(path.dirname(outDir)))}.zip`;
|
|
182
|
+
const bundlePath = path.join(bundleDir, bundleName);
|
|
183
|
+
await rm(bundlePath, { force: true }).catch(() => { });
|
|
184
|
+
try {
|
|
185
|
+
await runZip(options.zipCommand ?? "zip", bundlePath, sourcePaths, outDir);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const fileStat = await stat(bundlePath).catch(() => null);
|
|
191
|
+
if (!fileStat?.isFile()) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
const maxFileSize = options.maxFileSize ?? MAX_TELEGRAM_FILE_SIZE;
|
|
195
|
+
if (fileStat.size > maxFileSize) {
|
|
196
|
+
await rm(bundlePath, { force: true }).catch(() => { });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
name: bundleName,
|
|
201
|
+
relativePath: path.relative(outDir, bundlePath).split(path.sep).join("/"),
|
|
202
|
+
localPath: bundlePath,
|
|
203
|
+
sizeBytes: fileStat.size,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export async function pruneConnectorTurnDirs(workspace, options = {}) {
|
|
207
|
+
const maxAgeMs = options.maxAgeMs ?? DEFAULT_RETENTION_AGE_MS;
|
|
208
|
+
const now = options.now ?? Date.now();
|
|
209
|
+
const connectorDir = path.join(workspace, ".nordrelay");
|
|
210
|
+
const removedTurnDirs = await pruneChildDirs(path.join(connectorDir, "turns"), {
|
|
211
|
+
maxAgeMs,
|
|
212
|
+
maxDirs: options.maxTurnDirs ?? DEFAULT_MAX_TURN_DIRS,
|
|
213
|
+
now,
|
|
214
|
+
});
|
|
215
|
+
const removedInboxDirs = await pruneChildDirs(path.join(connectorDir, "inbox"), {
|
|
216
|
+
maxAgeMs,
|
|
217
|
+
maxDirs: options.maxInboxDirs ?? DEFAULT_MAX_INBOX_DIRS,
|
|
218
|
+
now,
|
|
219
|
+
});
|
|
220
|
+
return { removedTurnDirs, removedInboxDirs };
|
|
221
|
+
}
|
|
222
|
+
export function formatArtifactSummary(artifacts, skippedCount, omittedCount = 0) {
|
|
223
|
+
if (artifacts.length === 0 && skippedCount === 0 && omittedCount === 0) {
|
|
224
|
+
return "";
|
|
225
|
+
}
|
|
226
|
+
const lines = [];
|
|
227
|
+
if (artifacts.length > 0) {
|
|
228
|
+
lines.push(`📎 ${artifacts.length} artifact${artifacts.length === 1 ? "" : "s"} generated (${formatBytes(totalArtifactSize(artifacts))})`);
|
|
229
|
+
for (const artifact of artifacts.slice(0, 5)) {
|
|
230
|
+
lines.push(`- ${artifact.name} (${formatBytes(artifact.sizeBytes)})`);
|
|
231
|
+
}
|
|
232
|
+
if (artifacts.length > 5) {
|
|
233
|
+
lines.push(`- ${artifacts.length - 5} more`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (skippedCount > 0) {
|
|
237
|
+
lines.push(`⚠️ ${skippedCount} file${skippedCount === 1 ? "" : "s"} too large to send`);
|
|
238
|
+
}
|
|
239
|
+
if (omittedCount > 0) {
|
|
240
|
+
lines.push(`- ${omittedCount} more not shown`);
|
|
241
|
+
}
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
export function totalArtifactSize(artifacts) {
|
|
245
|
+
return artifacts.reduce((total, artifact) => total + artifact.sizeBytes, 0);
|
|
246
|
+
}
|
|
247
|
+
export function telegramArtifactFilename(artifact) {
|
|
248
|
+
return artifact.name.replace(/[\\/]+/g, "__");
|
|
249
|
+
}
|
|
250
|
+
export function isTelegramImagePreview(artifact) {
|
|
251
|
+
return /\.(?:png|jpe?g|webp|gif)$/i.test(artifact.name);
|
|
252
|
+
}
|
|
253
|
+
async function collectArtifactReportFromDir(currentDir, rootDir, maxFileSize, depth) {
|
|
254
|
+
if (depth > MAX_ARTIFACT_DEPTH) {
|
|
255
|
+
return { artifacts: [], skippedCount: 0 };
|
|
256
|
+
}
|
|
257
|
+
const entries = await readdir(currentDir, { withFileTypes: true }).catch(() => []);
|
|
258
|
+
const artifacts = [];
|
|
259
|
+
let skippedCount = 0;
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
if (shouldIgnoreEntry(entry.name) || entry.isSymbolicLink()) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
265
|
+
if (entry.isDirectory()) {
|
|
266
|
+
const nested = await collectArtifactReportFromDir(fullPath, rootDir, maxFileSize, depth + 1);
|
|
267
|
+
artifacts.push(...nested.artifacts);
|
|
268
|
+
skippedCount += nested.skippedCount;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const fileStat = await stat(fullPath).catch(() => null);
|
|
272
|
+
if (!fileStat?.isFile()) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (fileStat.size > maxFileSize) {
|
|
276
|
+
skippedCount += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const relativePath = path.relative(rootDir, fullPath).split(path.sep).join("/");
|
|
280
|
+
artifacts.push({
|
|
281
|
+
name: relativePath,
|
|
282
|
+
relativePath,
|
|
283
|
+
localPath: fullPath,
|
|
284
|
+
sizeBytes: fileStat.size,
|
|
285
|
+
modifiedAtMs: fileStat.mtimeMs,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return { artifacts, skippedCount };
|
|
289
|
+
}
|
|
290
|
+
async function collectRecentWorkspaceArtifactsFromDir(currentDir, rootDir, sinceMs, untilMs, maxFileSize, ignoreDirs, ignoreGlobs, depth) {
|
|
291
|
+
if (depth > MAX_ARTIFACT_DEPTH) {
|
|
292
|
+
return { artifacts: [], skippedCount: 0 };
|
|
293
|
+
}
|
|
294
|
+
const entries = await readdir(currentDir, { withFileTypes: true }).catch(() => []);
|
|
295
|
+
const artifacts = [];
|
|
296
|
+
let skippedCount = 0;
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
if (shouldIgnoreEntry(entry.name) || entry.isSymbolicLink()) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
302
|
+
const relativeEntryPath = path.relative(rootDir, fullPath).split(path.sep).join("/");
|
|
303
|
+
if (entry.isDirectory()) {
|
|
304
|
+
if (WORKSPACE_ARTIFACT_IGNORED_DIRS.has(entry.name) || ignoreDirs.has(entry.name) || ignoreDirs.has(relativeEntryPath)) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const nested = await collectRecentWorkspaceArtifactsFromDir(fullPath, rootDir, sinceMs, untilMs, maxFileSize, ignoreDirs, ignoreGlobs, depth + 1);
|
|
308
|
+
artifacts.push(...nested.artifacts);
|
|
309
|
+
skippedCount += nested.skippedCount;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const fileStat = await stat(fullPath).catch(() => null);
|
|
313
|
+
if (!fileStat?.isFile()) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (fileStat.mtimeMs < sinceMs || fileStat.mtimeMs > untilMs) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (fileStat.size > maxFileSize) {
|
|
320
|
+
skippedCount += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const relativePath = path.relative(rootDir, fullPath).split(path.sep).join("/");
|
|
324
|
+
if (ignoreGlobs.some((pattern) => matchesGlob(relativePath, pattern))) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
artifacts.push({
|
|
328
|
+
name: relativePath,
|
|
329
|
+
relativePath,
|
|
330
|
+
localPath: fullPath,
|
|
331
|
+
sizeBytes: fileStat.size,
|
|
332
|
+
modifiedAtMs: fileStat.mtimeMs,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return { artifacts, skippedCount };
|
|
336
|
+
}
|
|
337
|
+
async function pruneChildDirs(rootDir, options) {
|
|
338
|
+
const entries = await readdir(rootDir, { withFileTypes: true }).catch(() => []);
|
|
339
|
+
const dirs = [];
|
|
340
|
+
for (const entry of entries) {
|
|
341
|
+
if (!entry.isDirectory() || shouldIgnoreEntry(entry.name)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const fullPath = path.join(rootDir, entry.name);
|
|
345
|
+
const fileStat = await stat(fullPath).catch(() => null);
|
|
346
|
+
if (fileStat?.isDirectory()) {
|
|
347
|
+
dirs.push({ fullPath, mtimeMs: fileStat.mtimeMs });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
dirs.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
351
|
+
let removed = 0;
|
|
352
|
+
for (const [index, dir] of dirs.entries()) {
|
|
353
|
+
const expired = options.now - dir.mtimeMs > options.maxAgeMs;
|
|
354
|
+
const aboveLimit = index >= options.maxDirs;
|
|
355
|
+
if (!expired && !aboveLimit) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
await rm(dir.fullPath, { recursive: true, force: true }).catch(() => { });
|
|
359
|
+
removed += 1;
|
|
360
|
+
}
|
|
361
|
+
return removed;
|
|
362
|
+
}
|
|
363
|
+
function shouldIgnoreEntry(name) {
|
|
364
|
+
return IGNORED_PATTERNS.some((pattern) => pattern.test(name));
|
|
365
|
+
}
|
|
366
|
+
function artifactTurnsDir(workspace) {
|
|
367
|
+
return path.join(workspace, ".nordrelay", "turns");
|
|
368
|
+
}
|
|
369
|
+
function artifactTurnDir(workspace, turnId) {
|
|
370
|
+
return path.join(artifactTurnsDir(workspace), turnId);
|
|
371
|
+
}
|
|
372
|
+
function artifactManifestPath(workspace, turnId) {
|
|
373
|
+
return path.join(artifactTurnDir(workspace, turnId), "manifest.json");
|
|
374
|
+
}
|
|
375
|
+
async function readWorkspaceArtifactManifest(workspace, turnId, maxFileSize) {
|
|
376
|
+
const safeTurnId = sanitizeTurnId(turnId);
|
|
377
|
+
if (!safeTurnId) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const manifest = await readArtifactTurnManifest(artifactManifestPath(workspace, safeTurnId)).catch(() => null);
|
|
381
|
+
if (!manifest || manifest.source !== "workspace") {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const normalized = await normalizeManifestArtifacts(workspace, manifest, maxFileSize ?? MAX_TELEGRAM_FILE_SIZE);
|
|
385
|
+
if (normalized.artifacts.length === 0 && normalized.skippedCount === 0 && !normalized.omittedCount) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
turnId: safeTurnId,
|
|
390
|
+
outDir: workspace,
|
|
391
|
+
updatedAt: parseDate(manifest.updatedAt) ?? new Date(0),
|
|
392
|
+
artifacts: normalized.artifacts,
|
|
393
|
+
skippedCount: normalized.skippedCount,
|
|
394
|
+
omittedCount: normalized.omittedCount,
|
|
395
|
+
totalSizeBytes: totalArtifactSize(normalized.artifacts),
|
|
396
|
+
source: "workspace",
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async function readArtifactTurnManifest(filePath) {
|
|
400
|
+
const payload = JSON.parse(await readFile(filePath, "utf8"));
|
|
401
|
+
if (payload.version !== 1 || payload.source !== "workspace" || !Array.isArray(payload.artifacts)) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
version: 1,
|
|
406
|
+
source: "workspace",
|
|
407
|
+
turnId: typeof payload.turnId === "string" ? payload.turnId : path.basename(path.dirname(filePath)),
|
|
408
|
+
outDir: typeof payload.outDir === "string" ? payload.outDir : "",
|
|
409
|
+
updatedAt: typeof payload.updatedAt === "string" ? payload.updatedAt : new Date(0).toISOString(),
|
|
410
|
+
skippedCount: typeof payload.skippedCount === "number" ? payload.skippedCount : 0,
|
|
411
|
+
omittedCount: typeof payload.omittedCount === "number" ? payload.omittedCount : 0,
|
|
412
|
+
artifacts: payload.artifacts,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
async function normalizeManifestArtifacts(workspace, manifest, maxFileSize) {
|
|
416
|
+
const workspaceRoot = path.resolve(workspace);
|
|
417
|
+
const artifacts = [];
|
|
418
|
+
let skippedCount = manifest.skippedCount;
|
|
419
|
+
for (const artifact of manifest.artifacts) {
|
|
420
|
+
const relativePath = normalizeRelativePath(artifact.relativePath || artifact.name);
|
|
421
|
+
if (!relativePath) {
|
|
422
|
+
skippedCount += 1;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const localPath = path.resolve(workspaceRoot, relativePath);
|
|
426
|
+
if (!isPathInside(localPath, workspaceRoot)) {
|
|
427
|
+
skippedCount += 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
const fileStat = await stat(localPath).catch(() => null);
|
|
431
|
+
if (!fileStat?.isFile()) {
|
|
432
|
+
skippedCount += 1;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (fileStat.size > maxFileSize) {
|
|
436
|
+
skippedCount += 1;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
artifacts.push({
|
|
440
|
+
name: relativePath,
|
|
441
|
+
relativePath,
|
|
442
|
+
localPath,
|
|
443
|
+
sizeBytes: fileStat.size,
|
|
444
|
+
modifiedAtMs: fileStat.mtimeMs,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
artifacts,
|
|
449
|
+
skippedCount,
|
|
450
|
+
omittedCount: manifest.omittedCount,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function normalizeRelativePath(value) {
|
|
454
|
+
const normalized = value.split(/[\\/]+/).filter(Boolean).join("/");
|
|
455
|
+
if (!normalized || normalized.startsWith("../") || normalized === "..") {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
return normalized;
|
|
459
|
+
}
|
|
460
|
+
function isPathInside(candidate, root) {
|
|
461
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
462
|
+
}
|
|
463
|
+
function parseDate(value) {
|
|
464
|
+
const date = new Date(value);
|
|
465
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
466
|
+
}
|
|
467
|
+
function sanitizeTurnId(turnId) {
|
|
468
|
+
const trimmed = turnId.trim();
|
|
469
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return trimmed;
|
|
473
|
+
}
|
|
474
|
+
function runZip(zipCommand, bundlePath, sourcePaths, cwd) {
|
|
475
|
+
return new Promise((resolve, reject) => {
|
|
476
|
+
const child = spawn(zipCommand, ["-q", "-@", bundlePath], {
|
|
477
|
+
cwd,
|
|
478
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
479
|
+
});
|
|
480
|
+
let stderr = "";
|
|
481
|
+
child.stderr.on("data", (chunk) => {
|
|
482
|
+
stderr += String(chunk);
|
|
483
|
+
});
|
|
484
|
+
child.on("error", reject);
|
|
485
|
+
child.on("close", (code) => {
|
|
486
|
+
if (code === 0) {
|
|
487
|
+
resolve();
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
reject(new Error(stderr.trim() || `zip exited with code ${code}`));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
child.stdin.end(`${sourcePaths.join("\n")}\n`);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function sanitizeZipStem(stem) {
|
|
497
|
+
const cleaned = stem.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
498
|
+
return cleaned || "turn";
|
|
499
|
+
}
|
|
500
|
+
function matchesGlob(value, pattern) {
|
|
501
|
+
const escaped = pattern
|
|
502
|
+
.split("*")
|
|
503
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
504
|
+
.join(".*");
|
|
505
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
506
|
+
}
|
|
507
|
+
function formatBytes(bytes) {
|
|
508
|
+
if (bytes < 1024) {
|
|
509
|
+
return `${bytes} B`;
|
|
510
|
+
}
|
|
511
|
+
if (bytes < 1024 * 1024) {
|
|
512
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
513
|
+
}
|
|
514
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
515
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const UNSAFE_FILENAME_CHARS = /[^a-zA-Z0-9._-]/g;
|
|
5
|
+
const PATH_TRAVERSAL = /\.\./g;
|
|
6
|
+
export function sanitizeFilename(name) {
|
|
7
|
+
if (!name) {
|
|
8
|
+
return `file-${randomUUID().slice(0, 8)}`;
|
|
9
|
+
}
|
|
10
|
+
const basename = name.split(/[\\/]/).pop() ?? name;
|
|
11
|
+
const cleaned = basename.replace(PATH_TRAVERSAL, "").replace(UNSAFE_FILENAME_CHARS, "_");
|
|
12
|
+
return cleaned || `file-${randomUUID().slice(0, 8)}`;
|
|
13
|
+
}
|
|
14
|
+
export function inboxPath(workspace, turnId) {
|
|
15
|
+
return path.join(workspace, ".nordrelay", "inbox", turnId);
|
|
16
|
+
}
|
|
17
|
+
export function outboxPath(workspace, turnId) {
|
|
18
|
+
return path.join(workspace, ".nordrelay", "turns", turnId, "out");
|
|
19
|
+
}
|
|
20
|
+
export async function stageFile(buffer, originalName, mimeType, options) {
|
|
21
|
+
if (buffer.byteLength > options.maxFileSize) {
|
|
22
|
+
const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
|
|
23
|
+
const maxMB = Math.round(options.maxFileSize / 1024 / 1024);
|
|
24
|
+
throw new Error(`File too large (${sizeMB} MB, max ${maxMB} MB)`);
|
|
25
|
+
}
|
|
26
|
+
const safeName = sanitizeFilename(originalName);
|
|
27
|
+
const dir = inboxPath(options.workspace, options.turnId);
|
|
28
|
+
await mkdir(dir, { recursive: true });
|
|
29
|
+
const localPath = path.join(dir, safeName);
|
|
30
|
+
await writeFile(localPath, buffer);
|
|
31
|
+
return {
|
|
32
|
+
originalName,
|
|
33
|
+
safeName,
|
|
34
|
+
localPath,
|
|
35
|
+
mimeType,
|
|
36
|
+
sizeBytes: buffer.byteLength,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function buildFileInstructions(files, outDir) {
|
|
40
|
+
if (files.length === 0) {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
const lines = ["The following files were uploaded by the user and staged on disk:", ""];
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
lines.push(`- ${file.safeName} (${file.mimeType}, ${formatBytes(file.sizeBytes)}) → ${file.localPath}`);
|
|
46
|
+
}
|
|
47
|
+
lines.push("");
|
|
48
|
+
lines.push(`Write any output files to: ${outDir}`);
|
|
49
|
+
lines.push("The user will receive files from that directory after this turn completes.");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
export async function cleanupInbox(workspace, turnId) {
|
|
53
|
+
const dir = inboxPath(workspace, turnId);
|
|
54
|
+
try {
|
|
55
|
+
await rm(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Ignore cleanup failures.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function formatBytes(bytes) {
|
|
62
|
+
if (bytes < 1024) {
|
|
63
|
+
return `${bytes} B`;
|
|
64
|
+
}
|
|
65
|
+
if (bytes < 1024 * 1024) {
|
|
66
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
67
|
+
}
|
|
68
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
69
|
+
}
|