@foldrun/core 8.0.1 → 9.0.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/dist/src/history.d.ts +5 -0
- package/dist/src/history.js +31 -4
- package/dist/src/hooks.d.ts +2 -0
- package/dist/src/hooks.js +13 -0
- package/dist/src/library.js +4 -0
- package/dist/src/notify.d.ts +15 -1
- package/dist/src/notify.js +81 -11
- package/dist/src/runner.js +12 -7
- package/dist/src/secrets.js +8 -2
- package/dist/src/storage.d.ts +76 -4
- package/dist/src/storage.js +219 -22
- package/dist/src/store.d.ts +20 -2
- package/dist/src/store.js +50 -15
- package/dist/src/webhook.js +5 -1
- package/package.json +1 -1
- package/src/history.ts +31 -4
- package/src/hooks.ts +14 -0
- package/src/library.ts +3 -0
- package/src/notify.ts +82 -12
- package/src/runner.ts +12 -6
- package/src/secrets.ts +7 -2
- package/src/storage.ts +223 -19
- package/src/store.ts +49 -14
- package/src/webhook.ts +4 -1
package/dist/src/storage.js
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
import fs from "node:fs";
|
|
51
51
|
import path from "node:path";
|
|
52
52
|
import crypto from "node:crypto";
|
|
53
|
+
import { Readable } from "node:stream";
|
|
53
54
|
import { dataRoot, singleWorkspace } from "./paths.js";
|
|
54
55
|
import { assertSafeName, workspaceDir, STORAGE_DIR, adoptLegacyFilesDir } from "./store.js";
|
|
55
56
|
/** The directory a run sees, and the one this module mirrors into it.
|
|
@@ -59,6 +60,29 @@ export { STORAGE_DIR };
|
|
|
59
60
|
// ---------- limits ----------
|
|
60
61
|
const maxBytes = () => Number(process.env.FOLDRUN_STORAGE_MAX_MB ?? 512) * 1024 * 1024;
|
|
61
62
|
const quotaBytes = () => Number(process.env.FOLDRUN_STORAGE_QUOTA_MB ?? 5120) * 1024 * 1024;
|
|
63
|
+
/** The bytes a set of records occupies: distinct blobs, not records — two
|
|
64
|
+
* names for identical bytes are one object. A size that is not a positive
|
|
65
|
+
* number counts as nothing: indexes written before 30 Oct 2026 hold sizes a
|
|
66
|
+
* browser claimed, and a negative one used to lower the total. */
|
|
67
|
+
function bytesOf(files) {
|
|
68
|
+
const seen = new Map();
|
|
69
|
+
for (const f of files)
|
|
70
|
+
seen.set(f.sha, Number.isFinite(f.size) && f.size > 0 ? f.size : 0);
|
|
71
|
+
return [...seen.values()].reduce((a, b) => a + b, 0);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Refuse a workspace that is not there, as a 404 (30 Oct 2026).
|
|
75
|
+
*
|
|
76
|
+
* The store is keyed by workspace *name* and used to be created for any safe
|
|
77
|
+
* name on first write, so an editor got a fresh quota for every name they
|
|
78
|
+
* invented — stores no workspace list shows and no delete reaches. Every
|
|
79
|
+
* write here, and the routes that read, ask this first.
|
|
80
|
+
*/
|
|
81
|
+
export function requireWorkspace(tenant, workspace) {
|
|
82
|
+
if (!fs.existsSync(workspaceDir(tenant, workspace))) {
|
|
83
|
+
throw Object.assign(new Error(`workspace ${workspace} not found`), { status: 404 });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
62
86
|
// ---------- paths ----------
|
|
63
87
|
/**
|
|
64
88
|
* Hosted only. On a laptop `storage/` is just a folder in the user's own
|
|
@@ -178,10 +202,7 @@ export function getFile(tenant, workspace, rel) {
|
|
|
178
202
|
export function usedBytes(tenant, workspace) {
|
|
179
203
|
// Distinct blobs, not records: two names for identical bytes occupy one
|
|
180
204
|
// object, and billing the tenant twice for it would be a lie.
|
|
181
|
-
|
|
182
|
-
for (const f of readIndex(tenant, workspace).files)
|
|
183
|
-
seen.set(f.sha, f.size);
|
|
184
|
-
return [...seen.values()].reduce((a, b) => a + b, 0);
|
|
205
|
+
return bytesOf(readIndex(tenant, workspace).files);
|
|
185
206
|
}
|
|
186
207
|
// ---------- fs driver ----------
|
|
187
208
|
/** Where the fs driver keeps a blob. Exported because the run mirror
|
|
@@ -211,6 +232,19 @@ function fsDriver(tenant, workspace) {
|
|
|
211
232
|
async has(key) {
|
|
212
233
|
return fs.existsSync(at(key));
|
|
213
234
|
},
|
|
235
|
+
async size(key) {
|
|
236
|
+
try {
|
|
237
|
+
const st = fs.statSync(at(key));
|
|
238
|
+
return st.isFile() ? st.size : null;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
async open(key, range) {
|
|
245
|
+
const stream = fs.createReadStream(at(key), range ? { start: range.start, end: range.end } : {});
|
|
246
|
+
return Readable.toWeb(stream);
|
|
247
|
+
},
|
|
214
248
|
async remove(key) {
|
|
215
249
|
fs.rmSync(at(key), { force: true });
|
|
216
250
|
},
|
|
@@ -338,17 +372,23 @@ function target(cfg, key) {
|
|
|
338
372
|
* wrote can never render as a page on any origin. Without it, "the agent
|
|
339
373
|
* produced a report.html" and "stored XSS" are the same event.
|
|
340
374
|
*/
|
|
341
|
-
function presign(cfg, creds, method, key, ttlSec, extraQuery = {}, now = new Date()) {
|
|
375
|
+
function presign(cfg, creds, method, key, ttlSec, extraQuery = {}, now = new Date(), extraHeaders = {}) {
|
|
342
376
|
const { host, origin, canonicalUri } = target(cfg, key);
|
|
343
377
|
const { amzDate, dateStamp } = stamps(now);
|
|
344
378
|
const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
|
|
379
|
+
// Headers the request must carry exactly as signed. `content-length` on an
|
|
380
|
+
// upload is the one that matters: the store then refuses a body of any
|
|
381
|
+
// other length, so a presigned PUT cannot be used to park 5 GB under a key
|
|
382
|
+
// that was asked for as 2 MB (30 Oct 2026).
|
|
383
|
+
const headers = { host, ...Object.fromEntries(Object.entries(extraHeaders).map(([k, v]) => [k.toLowerCase(), v])) };
|
|
384
|
+
const signed = Object.keys(headers).sort();
|
|
345
385
|
const query = {
|
|
346
386
|
...extraQuery,
|
|
347
387
|
"X-Amz-Algorithm": "AWS4-HMAC-SHA256",
|
|
348
388
|
"X-Amz-Credential": `${creds.accessKeyId}/${scope}`,
|
|
349
389
|
"X-Amz-Date": amzDate,
|
|
350
390
|
"X-Amz-Expires": String(ttlSec),
|
|
351
|
-
"X-Amz-SignedHeaders": "
|
|
391
|
+
"X-Amz-SignedHeaders": signed.join(";"),
|
|
352
392
|
// A role's credentials are only valid with their token, and the token
|
|
353
393
|
// is part of what is signed.
|
|
354
394
|
...(creds.sessionToken ? { "X-Amz-Security-Token": creds.sessionToken } : {}),
|
|
@@ -361,8 +401,8 @@ function presign(cfg, creds, method, key, ttlSec, extraQuery = {}, now = new Dat
|
|
|
361
401
|
method,
|
|
362
402
|
canonicalUri,
|
|
363
403
|
canonicalQuery,
|
|
364
|
-
|
|
365
|
-
"
|
|
404
|
+
signed.map((h) => `${h}:${headers[h].trim()}\n`).join(""),
|
|
405
|
+
signed.join(";"),
|
|
366
406
|
"UNSIGNED-PAYLOAD",
|
|
367
407
|
].join("\n");
|
|
368
408
|
const stringToSign = [
|
|
@@ -375,7 +415,7 @@ function presign(cfg, creds, method, key, ttlSec, extraQuery = {}, now = new Dat
|
|
|
375
415
|
return `${origin}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
|
|
376
416
|
}
|
|
377
417
|
/** The header-signed form, for the calls the platform itself makes. */
|
|
378
|
-
async function s3Request(cfg, method, key, body, mime) {
|
|
418
|
+
async function s3Request(cfg, method, key, body, mime, extra = {}) {
|
|
379
419
|
const creds = await credentials(cfg);
|
|
380
420
|
const { host, origin, canonicalUri } = target(cfg, key);
|
|
381
421
|
const { amzDate, dateStamp } = stamps(new Date());
|
|
@@ -387,6 +427,9 @@ async function s3Request(cfg, method, key, body, mime) {
|
|
|
387
427
|
"x-amz-date": amzDate,
|
|
388
428
|
...(creds.sessionToken ? { "x-amz-security-token": creds.sessionToken } : {}),
|
|
389
429
|
...(mime ? { "content-type": mime } : {}),
|
|
430
|
+
// range on a streamed read, x-amz-copy-source on a copy. Lower-case,
|
|
431
|
+
// because the canonical form is.
|
|
432
|
+
...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k.toLowerCase(), v])),
|
|
390
433
|
};
|
|
391
434
|
const signed = Object.keys(headers).sort();
|
|
392
435
|
const canonicalHeaders = signed.map((h) => `${h}:${headers[h].trim()}\n`).join("");
|
|
@@ -433,6 +476,31 @@ function s3Driver(cfg) {
|
|
|
433
476
|
const res = await s3Request(cfg, "HEAD", key);
|
|
434
477
|
return res.ok;
|
|
435
478
|
},
|
|
479
|
+
async size(key) {
|
|
480
|
+
const res = await s3Request(cfg, "HEAD", key);
|
|
481
|
+
if (res.status === 404)
|
|
482
|
+
return null;
|
|
483
|
+
if (!res.ok)
|
|
484
|
+
await fail(res, "head");
|
|
485
|
+
const n = Number(res.headers.get("content-length"));
|
|
486
|
+
return Number.isSafeInteger(n) && n >= 0 ? n : null;
|
|
487
|
+
},
|
|
488
|
+
async open(key, range) {
|
|
489
|
+
const res = await s3Request(cfg, "GET", key, undefined, undefined, range ? { range: `bytes=${range.start}-${range.end}` } : {});
|
|
490
|
+
if (!res.ok || !res.body)
|
|
491
|
+
await fail(res, "get");
|
|
492
|
+
return res.body;
|
|
493
|
+
},
|
|
494
|
+
async copy(from, to) {
|
|
495
|
+
// CopyObject: the bytes move inside the store, never through here.
|
|
496
|
+
const res = await s3Request(cfg, "PUT", to, undefined, undefined, {
|
|
497
|
+
"x-amz-copy-source": `/${cfg.bucket}/${uriEncode(from, true)}`,
|
|
498
|
+
});
|
|
499
|
+
// S3 can answer 200 with an <Error> body when a copy fails part-way.
|
|
500
|
+
const text = res.ok ? await res.text() : "";
|
|
501
|
+
if (!res.ok || /<Error>/.test(text))
|
|
502
|
+
throw new Error(`s3 copy failed: ${res.status} ${text.slice(0, 300)}`);
|
|
503
|
+
},
|
|
436
504
|
async remove(key) {
|
|
437
505
|
const res = await s3Request(cfg, "DELETE", key);
|
|
438
506
|
// 404 on delete is the desired end state, not an error.
|
|
@@ -449,8 +517,8 @@ function s3Driver(cfg) {
|
|
|
449
517
|
"response-content-type": safe ?? mime,
|
|
450
518
|
});
|
|
451
519
|
},
|
|
452
|
-
async presignPut(key, _mime, ttlSec = 600) {
|
|
453
|
-
return presign(cfg, await credentials(cfg), "PUT", key, ttlSec);
|
|
520
|
+
async presignPut(key, _mime, ttlSec = 600, size) {
|
|
521
|
+
return presign(cfg, await credentials(cfg), "PUT", key, ttlSec, {}, new Date(), size === undefined ? {} : { "content-length": String(size) });
|
|
454
522
|
},
|
|
455
523
|
};
|
|
456
524
|
}
|
|
@@ -499,6 +567,9 @@ async function announceFiles(tenant, workspace, paths, by) {
|
|
|
499
567
|
}
|
|
500
568
|
}
|
|
501
569
|
export async function putFile(tenant, workspace, rel, body, by) {
|
|
570
|
+
// A person's upload. A run's harvest writes through putFileQuietly, into a
|
|
571
|
+
// workspace that was there when the run began.
|
|
572
|
+
requireWorkspace(tenant, workspace);
|
|
502
573
|
const record = await putFileQuietly(tenant, workspace, rel, body, by);
|
|
503
574
|
// A run's copy-back announces once, for all its files, from harvestFiles.
|
|
504
575
|
if (!by.startsWith("run:"))
|
|
@@ -538,14 +609,23 @@ async function putFileQuietly(tenant, workspace, rel, body, by) {
|
|
|
538
609
|
return record;
|
|
539
610
|
}
|
|
540
611
|
/** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
|
|
541
|
-
* putFile, minus the bytes — the platform never saw them.
|
|
542
|
-
|
|
612
|
+
* putFile, minus the bytes — the platform never saw them.
|
|
613
|
+
*
|
|
614
|
+
* `_claimed` is the size the browser says it sent, kept for the callers that
|
|
615
|
+
* pass it and never believed (30 Oct 2026): the record, the per-file limit
|
|
616
|
+
* and the quota all use the length the store reports. It used to store the
|
|
617
|
+
* claim, so an object registered as 0 bytes (or -1e12) sat outside every
|
|
618
|
+
* limit and lowered the quota line for everything after it. */
|
|
619
|
+
export async function registerUpload(tenant, workspace, rel, sha, _claimed, by) {
|
|
543
620
|
const norm = assertFilePath(rel);
|
|
544
621
|
if (!/^[0-9a-f]{64}$/.test(sha))
|
|
545
622
|
throw new Error("expected a sha256");
|
|
623
|
+
requireWorkspace(tenant, workspace);
|
|
546
624
|
const driver = driverFor(tenant, workspace);
|
|
547
|
-
|
|
548
|
-
|
|
625
|
+
const key = blobKey(tenant, workspace, sha);
|
|
626
|
+
// Trust nothing the browser said: not that the PUT landed, not its size.
|
|
627
|
+
const size = await driver.size(key);
|
|
628
|
+
if (size === null) {
|
|
549
629
|
throw new Error("no object at that key — the upload did not complete");
|
|
550
630
|
}
|
|
551
631
|
const index = readIndex(tenant, workspace);
|
|
@@ -558,10 +638,22 @@ export async function registerUpload(tenant, workspace, rel, sha, size, by) {
|
|
|
558
638
|
updatedAt: new Date().toISOString(),
|
|
559
639
|
by,
|
|
560
640
|
};
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
641
|
+
const files = [...index.files.filter((f) => f.path !== norm), record];
|
|
642
|
+
// putFile's two limits, on the real length. A refused object is removed:
|
|
643
|
+
// nothing names it, so it would sit in the bucket unreachable and billed —
|
|
644
|
+
// unless a record already names these bytes, which are then not ours to drop.
|
|
645
|
+
const refuse = async (message) => {
|
|
646
|
+
if (!index.files.some((f) => f.sha === sha))
|
|
647
|
+
await driver.remove(key).catch(() => { });
|
|
648
|
+
throw new Error(message);
|
|
649
|
+
};
|
|
650
|
+
if (size > maxBytes()) {
|
|
651
|
+
await refuse(`${norm} is ${Math.round(size / 1e6)}MB — the limit is ${maxBytes() / 1e6}MB`);
|
|
652
|
+
}
|
|
653
|
+
if (bytesOf(files) > quotaBytes() && bytesOf(files) > bytesOf(index.files)) {
|
|
654
|
+
await refuse(`workspace file quota exceeded (${quotaBytes() / 1e6}MB)`);
|
|
655
|
+
}
|
|
656
|
+
writeIndex(tenant, workspace, { version: 1, files });
|
|
565
657
|
if (previous && previous.sha !== sha)
|
|
566
658
|
await collect(tenant, workspace, previous.sha);
|
|
567
659
|
await announceFiles(tenant, workspace, [record.path], by);
|
|
@@ -573,6 +665,32 @@ export async function readFileBytes(tenant, workspace, rel) {
|
|
|
573
665
|
return null;
|
|
574
666
|
return driverFor(tenant, workspace).get(blobKey(tenant, workspace, record.sha));
|
|
575
667
|
}
|
|
668
|
+
/** A stored file's real length, from the store — a stat or a HEAD, never a
|
|
669
|
+
* read. Null when there is no such record or no object behind it. */
|
|
670
|
+
export async function storedSize(tenant, workspace, rel) {
|
|
671
|
+
const record = getFile(tenant, workspace, rel);
|
|
672
|
+
if (!record)
|
|
673
|
+
return null;
|
|
674
|
+
return driverFor(tenant, workspace).size(blobKey(tenant, workspace, record.sha));
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* A stored file as a stream, with its real length — for a route that passes
|
|
678
|
+
* the bytes on (a download, a public link) rather than reading them whole
|
|
679
|
+
* into memory. Null when there is no such record or no object behind it.
|
|
680
|
+
* `range` is inclusive and must lie inside the file (storedSize first, then
|
|
681
|
+
* byteRange, when the caller honours a Range header).
|
|
682
|
+
*/
|
|
683
|
+
export async function openFile(tenant, workspace, rel, range) {
|
|
684
|
+
const record = getFile(tenant, workspace, rel);
|
|
685
|
+
if (!record)
|
|
686
|
+
return null;
|
|
687
|
+
const driver = driverFor(tenant, workspace);
|
|
688
|
+
const key = blobKey(tenant, workspace, record.sha);
|
|
689
|
+
const size = await driver.size(key);
|
|
690
|
+
if (size === null)
|
|
691
|
+
return null;
|
|
692
|
+
return { record, size, body: await driver.open(key, range ?? undefined) };
|
|
693
|
+
}
|
|
576
694
|
export async function deleteFile(tenant, workspace, rel) {
|
|
577
695
|
const norm = assertFilePath(rel);
|
|
578
696
|
const index = readIndex(tenant, workspace);
|
|
@@ -624,11 +742,90 @@ export function previewType(rel) {
|
|
|
624
742
|
return PREVIEW_TYPES[path.extname(rel).toLowerCase()] ?? null;
|
|
625
743
|
}
|
|
626
744
|
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
627
|
-
export async function uploadUrl(tenant, workspace, rel, sha) {
|
|
628
|
-
assertFilePath(rel);
|
|
745
|
+
export async function uploadUrl(tenant, workspace, rel, sha, size) {
|
|
746
|
+
const norm = assertFilePath(rel);
|
|
629
747
|
if (!/^[0-9a-f]{64}$/.test(sha))
|
|
630
748
|
throw new Error("expected a sha256");
|
|
631
|
-
|
|
749
|
+
requireWorkspace(tenant, workspace);
|
|
750
|
+
// The limits, before a byte moves, and the length signed into the URL so
|
|
751
|
+
// the bucket refuses any other (30 Oct 2026). registerUpload checks the
|
|
752
|
+
// real object again; this is what keeps an oversized one from ever landing
|
|
753
|
+
// — an object that is PUT and never registered was otherwise unbounded.
|
|
754
|
+
if (size !== undefined) {
|
|
755
|
+
if (!Number.isSafeInteger(size) || size <= 0)
|
|
756
|
+
throw new Error("size must be the file's length in bytes, a whole number above 0");
|
|
757
|
+
if (size > maxBytes())
|
|
758
|
+
throw new Error(`${norm} is ${Math.round(size / 1e6)}MB — the limit is ${maxBytes() / 1e6}MB`);
|
|
759
|
+
const files = readIndex(tenant, workspace).files;
|
|
760
|
+
const next = [...files.filter((f) => f.path !== norm), { sha, size }];
|
|
761
|
+
if (bytesOf(next) > quotaBytes() && bytesOf(next) > bytesOf(files)) {
|
|
762
|
+
throw new Error(`workspace file quota exceeded (${quotaBytes() / 1e6}MB)`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return driverFor(tenant, workspace).presignPut(blobKey(tenant, workspace, sha), mimeFor(norm), undefined, size);
|
|
766
|
+
}
|
|
767
|
+
// ---------- a workspace deleted or renamed ----------
|
|
768
|
+
/** The store's directory for a name, without the workspace check —
|
|
769
|
+
* for the two calls below, which run when the folder is gone or moving. */
|
|
770
|
+
function storeDirOf(tenant, workspace) {
|
|
771
|
+
if (!fileStoreEnabled())
|
|
772
|
+
return null;
|
|
773
|
+
return storeDir(tenant, workspace);
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Drop everything a workspace stored: every object its index names, then the
|
|
777
|
+
* index (30 Oct 2026). Called when a workspace is deleted — the store is kept
|
|
778
|
+
* by name beside the folder, so it used to outlive it: the files stayed
|
|
779
|
+
* downloadable under the old name, public links kept serving them, and a new
|
|
780
|
+
* workspace given the same name inherited them. Best effort per object (a
|
|
781
|
+
* leaked object costs storage; the index going is what makes them
|
|
782
|
+
* unreachable), and a no-op when there is no store.
|
|
783
|
+
*/
|
|
784
|
+
export async function purgeWorkspaceStorage(tenant, workspace) {
|
|
785
|
+
const dir = storeDirOf(tenant, workspace);
|
|
786
|
+
if (!dir || !fs.existsSync(dir))
|
|
787
|
+
return;
|
|
788
|
+
const driver = driverFor(tenant, workspace);
|
|
789
|
+
if (driver.kind !== "fs") {
|
|
790
|
+
const shas = new Set(readIndex(tenant, workspace).files.map((f) => f.sha));
|
|
791
|
+
for (const sha of shas)
|
|
792
|
+
await driver.remove(blobKey(tenant, workspace, sha)).catch(() => { });
|
|
793
|
+
}
|
|
794
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Move a workspace's stored files to its new name (30 Oct 2026). A rename
|
|
798
|
+
* used to move the folder only: the renamed workspace lost its files, and
|
|
799
|
+
* the old name kept them for whoever took it next.
|
|
800
|
+
*
|
|
801
|
+
* On fs the blobs live under the store's directory, so moving the directory
|
|
802
|
+
* is the whole move. On s3 an object's key carries the workspace name, so
|
|
803
|
+
* every object is copied first; only once all copies landed does the index
|
|
804
|
+
* move and the old objects go. A copy that fails leaves the store under the
|
|
805
|
+
* old name, whole, and throws — the caller can put the folder back.
|
|
806
|
+
*
|
|
807
|
+
* Whatever a store under the new name held is dropped first: it belonged to a
|
|
808
|
+
* workspace of that name that no longer exists, never to this one.
|
|
809
|
+
*/
|
|
810
|
+
export async function moveWorkspaceStorage(tenant, from, to) {
|
|
811
|
+
const src = storeDirOf(tenant, from);
|
|
812
|
+
const dest = storeDirOf(tenant, to);
|
|
813
|
+
if (!src || !dest || src === dest)
|
|
814
|
+
return;
|
|
815
|
+
if (fs.existsSync(dest))
|
|
816
|
+
await purgeWorkspaceStorage(tenant, to);
|
|
817
|
+
if (!fs.existsSync(src))
|
|
818
|
+
return;
|
|
819
|
+
const driver = driverFor(tenant, from);
|
|
820
|
+
const shas = driver.kind === "fs" ? [] : [...new Set(readIndex(tenant, from).files.map((f) => f.sha))];
|
|
821
|
+
if (shas.length && !driver.copy)
|
|
822
|
+
throw new Error(`the ${driver.kind} store cannot move files to a new workspace name`);
|
|
823
|
+
for (const sha of shas)
|
|
824
|
+
await driver.copy(blobKey(tenant, from, sha), blobKey(tenant, to, sha));
|
|
825
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
826
|
+
fs.renameSync(src, dest);
|
|
827
|
+
for (const sha of shas)
|
|
828
|
+
await driver.remove(blobKey(tenant, from, sha)).catch(() => { });
|
|
632
829
|
}
|
|
633
830
|
// ---------- the run mirror ----------
|
|
634
831
|
/**
|
package/dist/src/store.d.ts
CHANGED
|
@@ -54,6 +54,16 @@ export declare function adoptLegacyFilesDir(parent: string): void;
|
|
|
54
54
|
*/
|
|
55
55
|
export declare function adoptLegacyVersionKey(dir: string): void;
|
|
56
56
|
export declare function assertSafeName(name: string, kind: string): void;
|
|
57
|
+
/**
|
|
58
|
+
* A name that is ONE folder under its parent — agents/<name>, never a path.
|
|
59
|
+
* Wider than SAFE_NAME on purpose: an agent imported from
|
|
60
|
+
* `.claude/agents/code_reviewer.md` keeps that file's name, and a run of it
|
|
61
|
+
* must keep working. What it refuses is what makes a name a path: a
|
|
62
|
+
* separator, a dot segment, a NUL. 30 Oct 2026: POST …/agents/<agent>/run
|
|
63
|
+
* and the runner joined the name unchecked, so `..%2F..%2F<tenant>%2F…`
|
|
64
|
+
* reached another account's agent.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isOneSegment(name: unknown): name is string;
|
|
57
67
|
export declare function workspaceDir(tenant: string, workspace: string): string;
|
|
58
68
|
/**
|
|
59
69
|
* The account root — where `library/`, account secrets and the account-wide
|
|
@@ -908,8 +918,16 @@ export declare function renameImpact(tenant: string, workspace: string): {
|
|
|
908
918
|
* there is nothing to migrate and nothing to lose.
|
|
909
919
|
*/
|
|
910
920
|
export declare function renameWorkspace(tenant: string, from: string, to: string): void;
|
|
911
|
-
/** Set a workspace's description, in AGENTS.md, without touching anything else.
|
|
912
|
-
|
|
921
|
+
/** Set a workspace's description, in AGENTS.md, without touching anything else.
|
|
922
|
+
*
|
|
923
|
+
* 30 Oct 2026: the value was spliced in as `description: ${text}`, so a
|
|
924
|
+
* newline in it wrote any other key (notify:, network_enforce:, past the
|
|
925
|
+
* settings' allowed list), and String.replace read `$'` / `$&` in it as
|
|
926
|
+
* patterns; the write went around writeWorkspaceFile, so History never saw
|
|
927
|
+
* it. Now the key is written as YAML (patchFrontmatter, which reads the
|
|
928
|
+
* result back and refuses an edit that changed any other key) and saved
|
|
929
|
+
* through writeWorkspaceFile, as a revision. */
|
|
930
|
+
export declare function setWorkspaceDescription(tenant: string, workspace: string, description: string, meta?: ChangeMeta): void;
|
|
913
931
|
/**
|
|
914
932
|
* Starter files for "+ New workspace" in the dashboard.
|
|
915
933
|
*
|
package/dist/src/store.js
CHANGED
|
@@ -24,12 +24,12 @@ import { expandPlatformVars } from "./host-env.js";
|
|
|
24
24
|
import { FLOW_TRIGGERS } from "./frontmatter-fields.js";
|
|
25
25
|
import fs from "node:fs";
|
|
26
26
|
import path from "node:path";
|
|
27
|
-
import { recordRevision, registerTreeReader } from "./history.js";
|
|
27
|
+
import { recordRevision, registerTreeReader, assertCommitId } from "./history.js";
|
|
28
28
|
import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.js";
|
|
29
29
|
import { platform } from "./platform.js";
|
|
30
30
|
import { toLF, asRaw } from "./eol.js";
|
|
31
31
|
import matter, { frontmatterErrorText, configMatter } from "./matter.js";
|
|
32
|
-
import { readAgentsMdKeys } from "./agents-md.js";
|
|
32
|
+
import { readAgentsMdKeys, patchFrontmatter } from "./agents-md.js";
|
|
33
33
|
/**
|
|
34
34
|
* The frontmatter of one markdown file, as the runtime reads it. Exported
|
|
35
35
|
* for the CLI: `foldrun probe` used to import gray-matter itself, which is
|
|
@@ -174,6 +174,18 @@ export function assertSafeName(name, kind) {
|
|
|
174
174
|
if (!SAFE_NAME.test(name))
|
|
175
175
|
throw new Error(`invalid ${kind} "${name}" — kebab-case only`);
|
|
176
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* A name that is ONE folder under its parent — agents/<name>, never a path.
|
|
179
|
+
* Wider than SAFE_NAME on purpose: an agent imported from
|
|
180
|
+
* `.claude/agents/code_reviewer.md` keeps that file's name, and a run of it
|
|
181
|
+
* must keep working. What it refuses is what makes a name a path: a
|
|
182
|
+
* separator, a dot segment, a NUL. 30 Oct 2026: POST …/agents/<agent>/run
|
|
183
|
+
* and the runner joined the name unchecked, so `..%2F..%2F<tenant>%2F…`
|
|
184
|
+
* reached another account's agent.
|
|
185
|
+
*/
|
|
186
|
+
export function isOneSegment(name) {
|
|
187
|
+
return typeof name === "string" && name !== "" && name !== "." && name !== ".." && !/[/\\\0]/.test(name);
|
|
188
|
+
}
|
|
177
189
|
export function workspaceDir(tenant, workspace) {
|
|
178
190
|
const single = singleWorkspace();
|
|
179
191
|
if (single)
|
|
@@ -343,6 +355,10 @@ export function saveWorkspace(tenant, workspace, files,
|
|
|
343
355
|
meta = {}) {
|
|
344
356
|
if (files.length > 500)
|
|
345
357
|
throw new Error("too many files");
|
|
358
|
+
// The commit becomes the revision's id: refused here, before the tree is
|
|
359
|
+
// replaced, never after it (history.ts assertCommitId).
|
|
360
|
+
if (meta.commit)
|
|
361
|
+
assertCommitId(meta.commit);
|
|
346
362
|
for (const f of files) {
|
|
347
363
|
const norm = path.normalize(f.path);
|
|
348
364
|
if (norm.startsWith("..") || path.isAbsolute(norm) || norm.startsWith("runs")) {
|
|
@@ -2377,6 +2393,14 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
|
|
|
2377
2393
|
throw new Error("file too large");
|
|
2378
2394
|
matter(content); // reject files whose frontmatter doesn't parse
|
|
2379
2395
|
const dir = workspaceDir(tenant, workspace);
|
|
2396
|
+
// A write goes INTO a workspace; it never makes one. The mkdir below is
|
|
2397
|
+
// recursive, so until 30 Oct 2026 a write to a name that did not exist
|
|
2398
|
+
// created that workspace — with workspace:write alone, past the
|
|
2399
|
+
// workspace:manage role and the plan's workspace count that POST
|
|
2400
|
+
// /api/workspaces, deploy and import apply. Those create through
|
|
2401
|
+
// saveWorkspace / applyImport, not through here.
|
|
2402
|
+
if (!fs.existsSync(dir))
|
|
2403
|
+
throw Object.assign(new Error(`workspace ${workspace} not found`), { status: 404 });
|
|
2380
2404
|
const p = path.join(dir, assertEditablePath(rel));
|
|
2381
2405
|
// Before anything is created: mkdir below would otherwise make folders
|
|
2382
2406
|
// through a linked directory too.
|
|
@@ -2587,6 +2611,15 @@ export function deleteWorkspace(tenant, workspace) {
|
|
|
2587
2611
|
if (!fs.existsSync(dir))
|
|
2588
2612
|
throw new Error(`workspace ${workspace} not found`);
|
|
2589
2613
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
2614
|
+
// Its file store too, kept by name beside the folder (storage.ts). Until
|
|
2615
|
+
// 30 Oct 2026 the store outlived the delete: its files stayed downloadable
|
|
2616
|
+
// and shared, and the next workspace given this name inherited them. The
|
|
2617
|
+
// index going is what makes them unreachable; the API route first calls
|
|
2618
|
+
// purgeWorkspaceStorage, which also removes the objects from a bucket
|
|
2619
|
+
// (async, so it cannot live in here).
|
|
2620
|
+
if (!singleWorkspace()) {
|
|
2621
|
+
fs.rmSync(path.join(dataRoot(), tenant, STORAGE_DIR, workspace), { recursive: true, force: true });
|
|
2622
|
+
}
|
|
2590
2623
|
workspaceChanged(tenant, workspace, "delete");
|
|
2591
2624
|
}
|
|
2592
2625
|
/**
|
|
@@ -2634,24 +2667,26 @@ export function renameWorkspace(tenant, from, to) {
|
|
|
2634
2667
|
fs.writeFileSync(agentsMd, raw.replace(/^name:.*$/m, `name: ${to}`));
|
|
2635
2668
|
}
|
|
2636
2669
|
}
|
|
2637
|
-
/** Set a workspace's description, in AGENTS.md, without touching anything else.
|
|
2638
|
-
|
|
2670
|
+
/** Set a workspace's description, in AGENTS.md, without touching anything else.
|
|
2671
|
+
*
|
|
2672
|
+
* 30 Oct 2026: the value was spliced in as `description: ${text}`, so a
|
|
2673
|
+
* newline in it wrote any other key (notify:, network_enforce:, past the
|
|
2674
|
+
* settings' allowed list), and String.replace read `$'` / `$&` in it as
|
|
2675
|
+
* patterns; the write went around writeWorkspaceFile, so History never saw
|
|
2676
|
+
* it. Now the key is written as YAML (patchFrontmatter, which reads the
|
|
2677
|
+
* result back and refuses an edit that changed any other key) and saved
|
|
2678
|
+
* through writeWorkspaceFile, as a revision. */
|
|
2679
|
+
export function setWorkspaceDescription(tenant, workspace, description, meta = {}) {
|
|
2639
2680
|
const file = path.join(workspaceDir(tenant, workspace), "AGENTS.md");
|
|
2640
2681
|
if (!fs.existsSync(file))
|
|
2641
2682
|
throw new Error("this workspace has no AGENTS.md");
|
|
2642
2683
|
const raw = fs.readFileSync(file, "utf8");
|
|
2643
|
-
|
|
2644
|
-
if (!block)
|
|
2684
|
+
if (!/^---\r?\n[\s\S]*?\r?\n---/.test(raw))
|
|
2645
2685
|
throw new Error("AGENTS.md has no frontmatter");
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
if (at === -1)
|
|
2651
|
-
lines.push(line);
|
|
2652
|
-
else
|
|
2653
|
-
lines[at] = line;
|
|
2654
|
-
fs.writeFileSync(file, raw.replace(open + body + close, open + lines.join("\n") + close));
|
|
2686
|
+
writeWorkspaceFile(tenant, workspace, "AGENTS.md", patchFrontmatter(raw, { description }), {
|
|
2687
|
+
message: "settings: description",
|
|
2688
|
+
...meta,
|
|
2689
|
+
});
|
|
2655
2690
|
}
|
|
2656
2691
|
/**
|
|
2657
2692
|
* Starter files for "+ New workspace" in the dashboard.
|
package/dist/src/webhook.js
CHANGED
|
@@ -42,7 +42,11 @@ export function rotateWebhook(tenant, workspace, flow) {
|
|
|
42
42
|
const hooks = readHooks(tenant, workspace);
|
|
43
43
|
hooks[flow] = { gen: (hooks[flow]?.gen ?? 0) + 1, rotatedAt: new Date().toISOString() };
|
|
44
44
|
const file = hooksFile(tenant, workspace);
|
|
45
|
-
|
|
45
|
+
// Into a workspace that exists, never making one (30 Oct 2026): the mkdir
|
|
46
|
+
// here turned a rotate under a mistyped name into a workspace holding only
|
|
47
|
+
// hooks.json, past the plan's workspace count.
|
|
48
|
+
if (!fs.existsSync(path.dirname(file)))
|
|
49
|
+
throw Object.assign(new Error(`workspace ${workspace} not found`), { status: 404 });
|
|
46
50
|
fs.writeFileSync(file, JSON.stringify(hooks, null, 2));
|
|
47
51
|
return webhookPath(tenant, workspace, flow);
|
|
48
52
|
}
|
package/package.json
CHANGED
package/src/history.ts
CHANGED
|
@@ -98,6 +98,16 @@ const historyDir = (tenant: string, scope: string) => {
|
|
|
98
98
|
return path.join(dataRoot(), tenant, ".history", scope);
|
|
99
99
|
};
|
|
100
100
|
|
|
101
|
+
/** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
|
|
102
|
+
* calls this BEFORE it writes: checked only in recordRevision, after the
|
|
103
|
+
* files were replaced, a bad commit left the new source live with no
|
|
104
|
+
* revision, no snapshot and no audit entry (30 Oct 2026). */
|
|
105
|
+
export function assertCommitId(commit: string) {
|
|
106
|
+
if (typeof commit !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(commit)) {
|
|
107
|
+
throw Object.assign(new Error(`commit "${String(commit).slice(0, 130)}" is not a commit id — letters, digits and . _ @ -, at most 121 characters`), { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
101
111
|
/** A local revision id: sortable by time, unique enough, visibly not a sha. */
|
|
102
112
|
export function newRevisionId(now = new Date()): string {
|
|
103
113
|
return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
@@ -121,8 +131,16 @@ export function recordRevision(
|
|
|
121
131
|
|
|
122
132
|
const dir = historyDir(tenant, scope);
|
|
123
133
|
fs.mkdirSync(dir, { recursive: true });
|
|
134
|
+
// A commit is the revision's id only the first time it is recorded. A
|
|
135
|
+
// second deploy of one commit (a CI re-run after a dashboard edit), or a
|
|
136
|
+
// `commit` that names a revision already here, overwrote that revision's
|
|
137
|
+
// record and its snapshot (30 Oct 2026) — History could no longer restore
|
|
138
|
+
// to it. It gets its own id now; `commit` still says which one it was,
|
|
139
|
+
// and a restore by commit (resolveRevision) still finds it.
|
|
140
|
+
if (meta.commit) assertCommitId(meta.commit);
|
|
141
|
+
const commitId = meta.commit && !fs.existsSync(path.join(dir, `${meta.commit}.json`)) ? meta.commit : null;
|
|
124
142
|
const rev: Revision = {
|
|
125
|
-
id: meta.id ??
|
|
143
|
+
id: meta.id ?? commitId ?? newRevisionId(),
|
|
126
144
|
at: new Date().toISOString(),
|
|
127
145
|
by: meta.by ?? "system",
|
|
128
146
|
message: meta.message ?? describe(changed),
|
|
@@ -301,7 +319,11 @@ export function resolveRevision(
|
|
|
301
319
|
hit = all.find((r) => Date.parse(r.at) <= moment);
|
|
302
320
|
} else {
|
|
303
321
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t)) throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
304
|
-
|
|
322
|
+
// An exact id first, then a commit: a commit deployed twice is recorded
|
|
323
|
+
// once under its own id and again under a fresh one naming it (30 Oct
|
|
324
|
+
// 2026, recordRevision), and newest-first with one predicate answered
|
|
325
|
+
// the first revision's id with the second's snapshot.
|
|
326
|
+
hit = all.find((r) => r.id === t) ?? all.find((r) => r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
|
|
305
327
|
}
|
|
306
328
|
return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
|
|
307
329
|
}
|
|
@@ -409,8 +431,13 @@ export function changedPathsBetween(tenant: string, scope: string, from: string
|
|
|
409
431
|
if (!from || !to) return null;
|
|
410
432
|
if (from === to) return [];
|
|
411
433
|
const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
|
|
412
|
-
|
|
413
|
-
const
|
|
434
|
+
// An exact id before a commit, as resolveRevision.
|
|
435
|
+
const at = (x: string) => {
|
|
436
|
+
const byId = revs.findIndex((r) => r.id === x);
|
|
437
|
+
return byId >= 0 ? byId : revs.findIndex((r) => r.commit === x);
|
|
438
|
+
};
|
|
439
|
+
const ti = at(to);
|
|
440
|
+
const fi = at(from);
|
|
414
441
|
if (ti < 0 || fi < 0 || ti > fi) return null;
|
|
415
442
|
return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
|
|
416
443
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -44,6 +44,11 @@ export const HOOK_TIMEOUT_MAX = 120;
|
|
|
44
44
|
export const HOOK_CODE_MAX = 100 * 1024;
|
|
45
45
|
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
46
46
|
|
|
47
|
+
/** A hook's name: its folder's name, never a path. */
|
|
48
|
+
export function isHookName(name: unknown): name is string {
|
|
49
|
+
return typeof name === "string" && NAME.test(name);
|
|
50
|
+
}
|
|
51
|
+
|
|
47
52
|
export interface HookDef {
|
|
48
53
|
name: string;
|
|
49
54
|
/** The folder holding hook.md and run.mjs. */
|
|
@@ -139,6 +144,15 @@ export function resolveHooks(args: {
|
|
|
139
144
|
for (const name of args.names) {
|
|
140
145
|
if (seen.has(name)) continue;
|
|
141
146
|
seen.add(name);
|
|
147
|
+
// Checked before the name is joined to anything (30 Oct 2026): readHookDef
|
|
148
|
+
// tested only basename(dir), so `../../<tenant>/workspaces/<ws>/hooks/<h>`
|
|
149
|
+
// passed, and the hook test route answered which folders exist anywhere
|
|
150
|
+
// on the server — on a laptop it would have run that run.mjs. An error,
|
|
151
|
+
// not missing: a step refuses either way, and the name is the problem.
|
|
152
|
+
if (!isHookName(name)) {
|
|
153
|
+
errors.push(`"${name}" is not a hook name — lowercase letters, digits and dashes (the hook's folder name)`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
142
156
|
const own = path.join(args.workspaceRoot, "hooks", name);
|
|
143
157
|
const shared = path.join(args.libraryRoot, "hooks", name);
|
|
144
158
|
const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
|
package/src/library.ts
CHANGED
|
@@ -282,6 +282,9 @@ export function deleteLibraryPath(
|
|
|
282
282
|
) {
|
|
283
283
|
const norm = path.normalize(rel);
|
|
284
284
|
if (norm.startsWith("..") || path.isAbsolute(norm)) throw new Error(`illegal path: ${rel}`);
|
|
285
|
+
// "." (or "./", "a/..") is the shelf itself: one call took every shared
|
|
286
|
+
// tool or skill from every workspace (30 Oct 2026). Name what goes.
|
|
287
|
+
if (norm === "." || norm === "./") throw new Error(`illegal path: ${rel} — name the file or folder to delete, not the whole ${kind} shelf`);
|
|
285
288
|
const target = path.join(libraryDir(tenant, kind), norm);
|
|
286
289
|
const gone: { path: string; before: string | null; after: null }[] = [];
|
|
287
290
|
if (fs.existsSync(target)) {
|