@foldrun/core 8.0.1 → 9.1.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.
Files changed (47) hide show
  1. package/dist/src/approvals.d.ts +18 -10
  2. package/dist/src/approvals.js +51 -1
  3. package/dist/src/catalog.d.ts +14 -0
  4. package/dist/src/catalog.js +28 -0
  5. package/dist/src/cron.d.ts +9 -0
  6. package/dist/src/cron.js +20 -0
  7. package/dist/src/frontmatter-fields.js +1 -1
  8. package/dist/src/frontmatter-schema.d.ts +5 -1
  9. package/dist/src/frontmatter-schema.js +40 -1
  10. package/dist/src/history.d.ts +5 -0
  11. package/dist/src/history.js +31 -4
  12. package/dist/src/hooks.d.ts +2 -0
  13. package/dist/src/hooks.js +13 -0
  14. package/dist/src/library.js +4 -0
  15. package/dist/src/notify.d.ts +21 -4
  16. package/dist/src/notify.js +105 -22
  17. package/dist/src/package.d.ts +5 -1
  18. package/dist/src/package.js +5 -3
  19. package/dist/src/runner.js +17 -9
  20. package/dist/src/scan.js +21 -0
  21. package/dist/src/secrets.d.ts +92 -10
  22. package/dist/src/secrets.js +258 -28
  23. package/dist/src/storage.d.ts +76 -4
  24. package/dist/src/storage.js +219 -22
  25. package/dist/src/store.d.ts +20 -2
  26. package/dist/src/store.js +50 -15
  27. package/dist/src/triggers.js +2 -2
  28. package/dist/src/webhook.d.ts +19 -3
  29. package/dist/src/webhook.js +19 -6
  30. package/package.json +1 -1
  31. package/src/approvals.ts +55 -1
  32. package/src/catalog.ts +27 -0
  33. package/src/cron.ts +19 -0
  34. package/src/frontmatter-fields.ts +1 -1
  35. package/src/frontmatter-schema.ts +38 -2
  36. package/src/history.ts +31 -4
  37. package/src/hooks.ts +14 -0
  38. package/src/library.ts +3 -0
  39. package/src/notify.ts +104 -23
  40. package/src/package.ts +5 -3
  41. package/src/runner.ts +17 -8
  42. package/src/scan.ts +23 -0
  43. package/src/secrets.ts +283 -34
  44. package/src/storage.ts +223 -19
  45. package/src/store.ts +49 -14
  46. package/src/triggers.ts +2 -2
  47. package/src/webhook.ts +26 -6
package/src/storage.ts CHANGED
@@ -51,6 +51,7 @@
51
51
  import fs from "node:fs";
52
52
  import path from "node:path";
53
53
  import crypto from "node:crypto";
54
+ import { Readable } from "node:stream";
54
55
  import { dataRoot, singleWorkspace } from "./paths.ts";
55
56
  import { assertSafeName, workspaceDir, STORAGE_DIR, adoptLegacyFilesDir } from "./store.ts";
56
57
 
@@ -85,6 +86,30 @@ interface Index {
85
86
  const maxBytes = () => Number(process.env.FOLDRUN_STORAGE_MAX_MB ?? 512) * 1024 * 1024;
86
87
  const quotaBytes = () => Number(process.env.FOLDRUN_STORAGE_QUOTA_MB ?? 5120) * 1024 * 1024;
87
88
 
89
+ /** The bytes a set of records occupies: distinct blobs, not records — two
90
+ * names for identical bytes are one object. A size that is not a positive
91
+ * number counts as nothing: indexes written before 30 Oct 2026 hold sizes a
92
+ * browser claimed, and a negative one used to lower the total. */
93
+ function bytesOf(files: Pick<FileRecord, "sha" | "size">[]): number {
94
+ const seen = new Map<string, number>();
95
+ for (const f of files) seen.set(f.sha, Number.isFinite(f.size) && f.size > 0 ? f.size : 0);
96
+ return [...seen.values()].reduce((a, b) => a + b, 0);
97
+ }
98
+
99
+ /**
100
+ * Refuse a workspace that is not there, as a 404 (30 Oct 2026).
101
+ *
102
+ * The store is keyed by workspace *name* and used to be created for any safe
103
+ * name on first write, so an editor got a fresh quota for every name they
104
+ * invented — stores no workspace list shows and no delete reaches. Every
105
+ * write here, and the routes that read, ask this first.
106
+ */
107
+ export function requireWorkspace(tenant: string, workspace: string): void {
108
+ if (!fs.existsSync(workspaceDir(tenant, workspace))) {
109
+ throw Object.assign(new Error(`workspace ${workspace} not found`), { status: 404 });
110
+ }
111
+ }
112
+
88
113
  // ---------- paths ----------
89
114
 
90
115
  /**
@@ -212,9 +237,7 @@ export function getFile(tenant: string, workspace: string, rel: string): FileRec
212
237
  export function usedBytes(tenant: string, workspace: string): number {
213
238
  // Distinct blobs, not records: two names for identical bytes occupy one
214
239
  // object, and billing the tenant twice for it would be a lie.
215
- const seen = new Map<string, number>();
216
- for (const f of readIndex(tenant, workspace).files) seen.set(f.sha, f.size);
217
- return [...seen.values()].reduce((a, b) => a + b, 0);
240
+ return bytesOf(readIndex(tenant, workspace).files);
218
241
  }
219
242
 
220
243
  // ---------- the driver interface ----------
@@ -224,6 +247,15 @@ export interface Driver {
224
247
  put(key: string, body: Buffer, mime: string): Promise<void>;
225
248
  get(key: string): Promise<Buffer>;
226
249
  has(key: string): Promise<boolean>;
250
+ /** The object's real length, or null when there is none — what the store
251
+ * holds, never what a browser said it sent (30 Oct 2026). */
252
+ size(key: string): Promise<number | null>;
253
+ /** The bytes as a stream, optionally one inclusive byte range — so a route
254
+ * hands a file on without holding all of it in memory. */
255
+ open(key: string, range?: { start: number; end: number }): Promise<ReadableStream<Uint8Array>>;
256
+ /** Server-side copy to another key (s3 only — the fs driver's blobs move
257
+ * with their directory). Used when a workspace is renamed. */
258
+ copy?(from: string, to: string): Promise<void>;
227
259
  remove(key: string): Promise<void>;
228
260
  /** A URL a browser can GET directly, or null when the driver has no such
229
261
  * thing (fs) and the route must stream the bytes itself. */
@@ -232,7 +264,9 @@ export interface Driver {
232
264
  presignGet(key: string, filename: string, mime: string, ttlSec?: number, inlineType?: string): Promise<string | null>;
233
265
  /** A URL a browser can PUT directly to, so upload bytes never transit the
234
266
  * dashboard process. Null on fs, where the route takes the body. */
235
- presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
267
+ /** `size`: sign the length in, so the store refuses a PUT of any other
268
+ * length (30 Oct 2026) — the per-file limit then holds at the bucket. */
269
+ presignPut(key: string, mime: string, ttlSec?: number, size?: number): Promise<string | null>;
236
270
  }
237
271
 
238
272
  // ---------- fs driver ----------
@@ -264,6 +298,18 @@ function fsDriver(tenant: string, workspace: string): Driver {
264
298
  async has(key) {
265
299
  return fs.existsSync(at(key));
266
300
  },
301
+ async size(key) {
302
+ try {
303
+ const st = fs.statSync(at(key));
304
+ return st.isFile() ? st.size : null;
305
+ } catch {
306
+ return null;
307
+ }
308
+ },
309
+ async open(key, range) {
310
+ const stream = fs.createReadStream(at(key), range ? { start: range.start, end: range.end } : {});
311
+ return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;
312
+ },
267
313
  async remove(key) {
268
314
  fs.rmSync(at(key), { force: true });
269
315
  },
@@ -435,10 +481,17 @@ function presign(
435
481
  ttlSec: number,
436
482
  extraQuery: Record<string, string> = {},
437
483
  now = new Date(),
484
+ extraHeaders: Record<string, string> = {},
438
485
  ): string {
439
486
  const { host, origin, canonicalUri } = target(cfg, key);
440
487
  const { amzDate, dateStamp } = stamps(now);
441
488
  const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
489
+ // Headers the request must carry exactly as signed. `content-length` on an
490
+ // upload is the one that matters: the store then refuses a body of any
491
+ // other length, so a presigned PUT cannot be used to park 5 GB under a key
492
+ // that was asked for as 2 MB (30 Oct 2026).
493
+ const headers: Record<string, string> = { host, ...Object.fromEntries(Object.entries(extraHeaders).map(([k, v]) => [k.toLowerCase(), v])) };
494
+ const signed = Object.keys(headers).sort();
442
495
 
443
496
  const query: Record<string, string> = {
444
497
  ...extraQuery,
@@ -446,7 +499,7 @@ function presign(
446
499
  "X-Amz-Credential": `${creds.accessKeyId}/${scope}`,
447
500
  "X-Amz-Date": amzDate,
448
501
  "X-Amz-Expires": String(ttlSec),
449
- "X-Amz-SignedHeaders": "host",
502
+ "X-Amz-SignedHeaders": signed.join(";"),
450
503
  // A role's credentials are only valid with their token, and the token
451
504
  // is part of what is signed.
452
505
  ...(creds.sessionToken ? { "X-Amz-Security-Token": creds.sessionToken } : {}),
@@ -460,8 +513,8 @@ function presign(
460
513
  method,
461
514
  canonicalUri,
462
515
  canonicalQuery,
463
- `host:${host}\n`,
464
- "host",
516
+ signed.map((h) => `${h}:${headers[h].trim()}\n`).join(""),
517
+ signed.join(";"),
465
518
  "UNSIGNED-PAYLOAD",
466
519
  ].join("\n");
467
520
 
@@ -483,6 +536,7 @@ async function s3Request(
483
536
  key: string,
484
537
  body?: Buffer,
485
538
  mime?: string,
539
+ extra: Record<string, string> = {},
486
540
  ): Promise<Response> {
487
541
  const creds = await credentials(cfg);
488
542
  const { host, origin, canonicalUri } = target(cfg, key);
@@ -496,6 +550,9 @@ async function s3Request(
496
550
  "x-amz-date": amzDate,
497
551
  ...(creds.sessionToken ? { "x-amz-security-token": creds.sessionToken } : {}),
498
552
  ...(mime ? { "content-type": mime } : {}),
553
+ // range on a streamed read, x-amz-copy-source on a copy. Lower-case,
554
+ // because the canonical form is.
555
+ ...Object.fromEntries(Object.entries(extra).map(([k, v]) => [k.toLowerCase(), v])),
499
556
  };
500
557
  const signed = Object.keys(headers).sort();
501
558
  const canonicalHeaders = signed.map((h) => `${h}:${headers[h].trim()}\n`).join("");
@@ -545,6 +602,27 @@ function s3Driver(cfg: S3Config): Driver {
545
602
  const res = await s3Request(cfg, "HEAD", key);
546
603
  return res.ok;
547
604
  },
605
+ async size(key) {
606
+ const res = await s3Request(cfg, "HEAD", key);
607
+ if (res.status === 404) return null;
608
+ if (!res.ok) await fail(res, "head");
609
+ const n = Number(res.headers.get("content-length"));
610
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
611
+ },
612
+ async open(key, range) {
613
+ const res = await s3Request(cfg, "GET", key, undefined, undefined, range ? { range: `bytes=${range.start}-${range.end}` } : {});
614
+ if (!res.ok || !res.body) await fail(res, "get");
615
+ return res.body as ReadableStream<Uint8Array>;
616
+ },
617
+ async copy(from, to) {
618
+ // CopyObject: the bytes move inside the store, never through here.
619
+ const res = await s3Request(cfg, "PUT", to, undefined, undefined, {
620
+ "x-amz-copy-source": `/${cfg.bucket}/${uriEncode(from, true)}`,
621
+ });
622
+ // S3 can answer 200 with an <Error> body when a copy fails part-way.
623
+ const text = res.ok ? await res.text() : "";
624
+ if (!res.ok || /<Error>/.test(text)) throw new Error(`s3 copy failed: ${res.status} ${text.slice(0, 300)}`);
625
+ },
548
626
  async remove(key) {
549
627
  const res = await s3Request(cfg, "DELETE", key);
550
628
  // 404 on delete is the desired end state, not an error.
@@ -560,8 +638,8 @@ function s3Driver(cfg: S3Config): Driver {
560
638
  "response-content-type": safe ?? mime,
561
639
  });
562
640
  },
563
- async presignPut(key, _mime, ttlSec = 600) {
564
- return presign(cfg, await credentials(cfg), "PUT", key, ttlSec);
641
+ async presignPut(key, _mime, ttlSec = 600, size) {
642
+ return presign(cfg, await credentials(cfg), "PUT", key, ttlSec, {}, new Date(), size === undefined ? {} : { "content-length": String(size) });
565
643
  },
566
644
  };
567
645
  }
@@ -619,6 +697,9 @@ export async function putFile(
619
697
  body: Buffer,
620
698
  by: string,
621
699
  ): Promise<FileRecord> {
700
+ // A person's upload. A run's harvest writes through putFileQuietly, into a
701
+ // workspace that was there when the run began.
702
+ requireWorkspace(tenant, workspace);
622
703
  const record = await putFileQuietly(tenant, workspace, rel, body, by);
623
704
  // A run's copy-back announces once, for all its files, from harvestFiles.
624
705
  if (!by.startsWith("run:")) await announceFiles(tenant, workspace, [record.path], by);
@@ -666,20 +747,29 @@ async function putFileQuietly(
666
747
  }
667
748
 
668
749
  /** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
669
- * putFile, minus the bytes — the platform never saw them. */
750
+ * putFile, minus the bytes — the platform never saw them.
751
+ *
752
+ * `_claimed` is the size the browser says it sent, kept for the callers that
753
+ * pass it and never believed (30 Oct 2026): the record, the per-file limit
754
+ * and the quota all use the length the store reports. It used to store the
755
+ * claim, so an object registered as 0 bytes (or -1e12) sat outside every
756
+ * limit and lowered the quota line for everything after it. */
670
757
  export async function registerUpload(
671
758
  tenant: string,
672
759
  workspace: string,
673
760
  rel: string,
674
761
  sha: string,
675
- size: number,
762
+ _claimed: number,
676
763
  by: string,
677
764
  ): Promise<FileRecord> {
678
765
  const norm = assertFilePath(rel);
679
766
  if (!/^[0-9a-f]{64}$/.test(sha)) throw new Error("expected a sha256");
767
+ requireWorkspace(tenant, workspace);
680
768
  const driver = driverFor(tenant, workspace);
681
- // Trust nothing the browser said about whether the PUT landed.
682
- if (!(await driver.has(blobKey(tenant, workspace, sha)))) {
769
+ const key = blobKey(tenant, workspace, sha);
770
+ // Trust nothing the browser said: not that the PUT landed, not its size.
771
+ const size = await driver.size(key);
772
+ if (size === null) {
683
773
  throw new Error("no object at that key — the upload did not complete");
684
774
  }
685
775
  const index = readIndex(tenant, workspace);
@@ -692,10 +782,21 @@ export async function registerUpload(
692
782
  updatedAt: new Date().toISOString(),
693
783
  by,
694
784
  };
695
- writeIndex(tenant, workspace, {
696
- version: 1,
697
- files: [...index.files.filter((f) => f.path !== norm), record],
698
- });
785
+ const files = [...index.files.filter((f) => f.path !== norm), record];
786
+ // putFile's two limits, on the real length. A refused object is removed:
787
+ // nothing names it, so it would sit in the bucket unreachable and billed —
788
+ // unless a record already names these bytes, which are then not ours to drop.
789
+ const refuse = async (message: string): Promise<never> => {
790
+ if (!index.files.some((f) => f.sha === sha)) await driver.remove(key).catch(() => {});
791
+ throw new Error(message);
792
+ };
793
+ if (size > maxBytes()) {
794
+ await refuse(`${norm} is ${Math.round(size / 1e6)}MB — the limit is ${maxBytes() / 1e6}MB`);
795
+ }
796
+ if (bytesOf(files) > quotaBytes() && bytesOf(files) > bytesOf(index.files)) {
797
+ await refuse(`workspace file quota exceeded (${quotaBytes() / 1e6}MB)`);
798
+ }
799
+ writeIndex(tenant, workspace, { version: 1, files });
699
800
  if (previous && previous.sha !== sha) await collect(tenant, workspace, previous.sha);
700
801
  await announceFiles(tenant, workspace, [record.path], by);
701
802
  return record;
@@ -711,6 +812,36 @@ export async function readFileBytes(
711
812
  return driverFor(tenant, workspace).get(blobKey(tenant, workspace, record.sha));
712
813
  }
713
814
 
815
+ /** A stored file's real length, from the store — a stat or a HEAD, never a
816
+ * read. Null when there is no such record or no object behind it. */
817
+ export async function storedSize(tenant: string, workspace: string, rel: string): Promise<number | null> {
818
+ const record = getFile(tenant, workspace, rel);
819
+ if (!record) return null;
820
+ return driverFor(tenant, workspace).size(blobKey(tenant, workspace, record.sha));
821
+ }
822
+
823
+ /**
824
+ * A stored file as a stream, with its real length — for a route that passes
825
+ * the bytes on (a download, a public link) rather than reading them whole
826
+ * into memory. Null when there is no such record or no object behind it.
827
+ * `range` is inclusive and must lie inside the file (storedSize first, then
828
+ * byteRange, when the caller honours a Range header).
829
+ */
830
+ export async function openFile(
831
+ tenant: string,
832
+ workspace: string,
833
+ rel: string,
834
+ range?: { start: number; end: number } | null,
835
+ ): Promise<{ record: FileRecord; size: number; body: ReadableStream<Uint8Array> } | null> {
836
+ const record = getFile(tenant, workspace, rel);
837
+ if (!record) return null;
838
+ const driver = driverFor(tenant, workspace);
839
+ const key = blobKey(tenant, workspace, record.sha);
840
+ const size = await driver.size(key);
841
+ if (size === null) return null;
842
+ return { record, size, body: await driver.open(key, range ?? undefined) };
843
+ }
844
+
714
845
  export async function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean> {
715
846
  const norm = assertFilePath(rel);
716
847
  const index = readIndex(tenant, workspace);
@@ -779,10 +910,83 @@ export async function uploadUrl(
779
910
  workspace: string,
780
911
  rel: string,
781
912
  sha: string,
913
+ size?: number,
782
914
  ): Promise<string | null> {
783
- assertFilePath(rel);
915
+ const norm = assertFilePath(rel);
784
916
  if (!/^[0-9a-f]{64}$/.test(sha)) throw new Error("expected a sha256");
785
- return driverFor(tenant, workspace).presignPut(blobKey(tenant, workspace, sha), mimeFor(rel));
917
+ requireWorkspace(tenant, workspace);
918
+ // The limits, before a byte moves, and the length signed into the URL so
919
+ // the bucket refuses any other (30 Oct 2026). registerUpload checks the
920
+ // real object again; this is what keeps an oversized one from ever landing
921
+ // — an object that is PUT and never registered was otherwise unbounded.
922
+ if (size !== undefined) {
923
+ if (!Number.isSafeInteger(size) || size <= 0) throw new Error("size must be the file's length in bytes, a whole number above 0");
924
+ if (size > maxBytes()) throw new Error(`${norm} is ${Math.round(size / 1e6)}MB — the limit is ${maxBytes() / 1e6}MB`);
925
+ const files = readIndex(tenant, workspace).files;
926
+ const next = [...files.filter((f) => f.path !== norm), { sha, size }];
927
+ if (bytesOf(next) > quotaBytes() && bytesOf(next) > bytesOf(files)) {
928
+ throw new Error(`workspace file quota exceeded (${quotaBytes() / 1e6}MB)`);
929
+ }
930
+ }
931
+ return driverFor(tenant, workspace).presignPut(blobKey(tenant, workspace, sha), mimeFor(norm), undefined, size);
932
+ }
933
+
934
+ // ---------- a workspace deleted or renamed ----------
935
+
936
+ /** The store's directory for a name, without the workspace check —
937
+ * for the two calls below, which run when the folder is gone or moving. */
938
+ function storeDirOf(tenant: string, workspace: string): string | null {
939
+ if (!fileStoreEnabled()) return null;
940
+ return storeDir(tenant, workspace);
941
+ }
942
+
943
+ /**
944
+ * Drop everything a workspace stored: every object its index names, then the
945
+ * index (30 Oct 2026). Called when a workspace is deleted — the store is kept
946
+ * by name beside the folder, so it used to outlive it: the files stayed
947
+ * downloadable under the old name, public links kept serving them, and a new
948
+ * workspace given the same name inherited them. Best effort per object (a
949
+ * leaked object costs storage; the index going is what makes them
950
+ * unreachable), and a no-op when there is no store.
951
+ */
952
+ export async function purgeWorkspaceStorage(tenant: string, workspace: string): Promise<void> {
953
+ const dir = storeDirOf(tenant, workspace);
954
+ if (!dir || !fs.existsSync(dir)) return;
955
+ const driver = driverFor(tenant, workspace);
956
+ if (driver.kind !== "fs") {
957
+ const shas = new Set(readIndex(tenant, workspace).files.map((f) => f.sha));
958
+ for (const sha of shas) await driver.remove(blobKey(tenant, workspace, sha)).catch(() => {});
959
+ }
960
+ fs.rmSync(dir, { recursive: true, force: true });
961
+ }
962
+
963
+ /**
964
+ * Move a workspace's stored files to its new name (30 Oct 2026). A rename
965
+ * used to move the folder only: the renamed workspace lost its files, and
966
+ * the old name kept them for whoever took it next.
967
+ *
968
+ * On fs the blobs live under the store's directory, so moving the directory
969
+ * is the whole move. On s3 an object's key carries the workspace name, so
970
+ * every object is copied first; only once all copies landed does the index
971
+ * move and the old objects go. A copy that fails leaves the store under the
972
+ * old name, whole, and throws — the caller can put the folder back.
973
+ *
974
+ * Whatever a store under the new name held is dropped first: it belonged to a
975
+ * workspace of that name that no longer exists, never to this one.
976
+ */
977
+ export async function moveWorkspaceStorage(tenant: string, from: string, to: string): Promise<void> {
978
+ const src = storeDirOf(tenant, from);
979
+ const dest = storeDirOf(tenant, to);
980
+ if (!src || !dest || src === dest) return;
981
+ if (fs.existsSync(dest)) await purgeWorkspaceStorage(tenant, to);
982
+ if (!fs.existsSync(src)) return;
983
+ const driver = driverFor(tenant, from);
984
+ const shas = driver.kind === "fs" ? [] : [...new Set(readIndex(tenant, from).files.map((f) => f.sha))];
985
+ if (shas.length && !driver.copy) throw new Error(`the ${driver.kind} store cannot move files to a new workspace name`);
986
+ for (const sha of shas) await driver.copy!(blobKey(tenant, from, sha), blobKey(tenant, to, sha));
987
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
988
+ fs.renameSync(src, dest);
989
+ for (const sha of shas) await driver.remove(blobKey(tenant, from, sha)).catch(() => {});
786
990
  }
787
991
 
788
992
  // ---------- the run mirror ----------
package/src/store.ts CHANGED
@@ -26,14 +26,14 @@ import { EFFORT_LEVELS, FLOW_TRIGGERS } from "./frontmatter-fields.ts";
26
26
  import type { BrowserPodOutcome } from "./browser-pod.ts";
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
- import { recordRevision, registerTreeReader, type RevisionFile } from "./history.ts";
29
+ import { recordRevision, registerTreeReader, assertCommitId, type RevisionFile } from "./history.ts";
30
30
  import { dataRoot, singleWorkspace, singleAccountRoot, entriesNoFollow } from "./paths.ts";
31
31
  import { platform } from "./platform.ts";
32
32
  import { toLF, asRaw } from "./eol.ts";
33
33
  import type { TestEffect } from "./test-mode.ts";
34
34
  import type { OperatorEvent } from "./operator.ts";
35
35
  import matter, { frontmatterErrorText, configMatter } from "./matter.ts";
36
- import { readAgentsMdKeys } from "./agents-md.ts";
36
+ import { readAgentsMdKeys, patchFrontmatter } from "./agents-md.ts";
37
37
 
38
38
  /**
39
39
  * The frontmatter of one markdown file, as the runtime reads it. Exported
@@ -184,6 +184,19 @@ export function assertSafeName(name: string, kind: string) {
184
184
  if (!SAFE_NAME.test(name)) throw new Error(`invalid ${kind} "${name}" — kebab-case only`);
185
185
  }
186
186
 
187
+ /**
188
+ * A name that is ONE folder under its parent — agents/<name>, never a path.
189
+ * Wider than SAFE_NAME on purpose: an agent imported from
190
+ * `.claude/agents/code_reviewer.md` keeps that file's name, and a run of it
191
+ * must keep working. What it refuses is what makes a name a path: a
192
+ * separator, a dot segment, a NUL. 30 Oct 2026: POST …/agents/<agent>/run
193
+ * and the runner joined the name unchecked, so `..%2F..%2F<tenant>%2F…`
194
+ * reached another account's agent.
195
+ */
196
+ export function isOneSegment(name: unknown): name is string {
197
+ return typeof name === "string" && name !== "" && name !== "." && name !== ".." && !/[/\\\0]/.test(name);
198
+ }
199
+
187
200
  export function workspaceDir(tenant: string, workspace: string) {
188
201
  const single = singleWorkspace();
189
202
  if (single) return path.resolve(single);
@@ -382,6 +395,9 @@ export function saveWorkspace(
382
395
  } = {},
383
396
  ) {
384
397
  if (files.length > 500) throw new Error("too many files");
398
+ // The commit becomes the revision's id: refused here, before the tree is
399
+ // replaced, never after it (history.ts assertCommitId).
400
+ if (meta.commit) assertCommitId(meta.commit);
385
401
  for (const f of files) {
386
402
  const norm = path.normalize(f.path);
387
403
  if (norm.startsWith("..") || path.isAbsolute(norm) || norm.startsWith("runs")) {
@@ -2856,6 +2872,13 @@ export function writeWorkspaceFile(
2856
2872
  if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
2857
2873
  matter(content); // reject files whose frontmatter doesn't parse
2858
2874
  const dir = workspaceDir(tenant, workspace);
2875
+ // A write goes INTO a workspace; it never makes one. The mkdir below is
2876
+ // recursive, so until 30 Oct 2026 a write to a name that did not exist
2877
+ // created that workspace — with workspace:write alone, past the
2878
+ // workspace:manage role and the plan's workspace count that POST
2879
+ // /api/workspaces, deploy and import apply. Those create through
2880
+ // saveWorkspace / applyImport, not through here.
2881
+ if (!fs.existsSync(dir)) throw Object.assign(new Error(`workspace ${workspace} not found`), { status: 404 });
2859
2882
  const p = path.join(dir, assertEditablePath(rel));
2860
2883
  // Before anything is created: mkdir below would otherwise make folders
2861
2884
  // through a linked directory too.
@@ -3069,6 +3092,15 @@ export function deleteWorkspace(tenant: string, workspace: string) {
3069
3092
  const dir = workspaceDir(tenant, workspace);
3070
3093
  if (!fs.existsSync(dir)) throw new Error(`workspace ${workspace} not found`);
3071
3094
  fs.rmSync(dir, { recursive: true, force: true });
3095
+ // Its file store too, kept by name beside the folder (storage.ts). Until
3096
+ // 30 Oct 2026 the store outlived the delete: its files stayed downloadable
3097
+ // and shared, and the next workspace given this name inherited them. The
3098
+ // index going is what makes them unreachable; the API route first calls
3099
+ // purgeWorkspaceStorage, which also removes the objects from a bucket
3100
+ // (async, so it cannot live in here).
3101
+ if (!singleWorkspace()) {
3102
+ fs.rmSync(path.join(dataRoot(), tenant, STORAGE_DIR, workspace), { recursive: true, force: true });
3103
+ }
3072
3104
  workspaceChanged(tenant, workspace, "delete");
3073
3105
  }
3074
3106
 
@@ -3120,23 +3152,26 @@ export function renameWorkspace(tenant: string, from: string, to: string) {
3120
3152
  }
3121
3153
  }
3122
3154
 
3123
- /** Set a workspace's description, in AGENTS.md, without touching anything else. */
3124
- export function setWorkspaceDescription(tenant: string, workspace: string, description: string) {
3155
+ /** Set a workspace's description, in AGENTS.md, without touching anything else.
3156
+ *
3157
+ * 30 Oct 2026: the value was spliced in as `description: ${text}`, so a
3158
+ * newline in it wrote any other key (notify:, network_enforce:, past the
3159
+ * settings' allowed list), and String.replace read `$'` / `$&` in it as
3160
+ * patterns; the write went around writeWorkspaceFile, so History never saw
3161
+ * it. Now the key is written as YAML (patchFrontmatter, which reads the
3162
+ * result back and refuses an edit that changed any other key) and saved
3163
+ * through writeWorkspaceFile, as a revision. */
3164
+ export function setWorkspaceDescription(tenant: string, workspace: string, description: string, meta: ChangeMeta = {}) {
3125
3165
  const file = path.join(workspaceDir(tenant, workspace), "AGENTS.md");
3126
3166
  if (!fs.existsSync(file)) throw new Error("this workspace has no AGENTS.md");
3127
3167
 
3128
3168
  const raw = fs.readFileSync(file, "utf8");
3129
- const block = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---)/);
3130
- if (!block) throw new Error("AGENTS.md has no frontmatter");
3169
+ if (!/^---\r?\n[\s\S]*?\r?\n---/.test(raw)) throw new Error("AGENTS.md has no frontmatter");
3131
3170
 
3132
- const [, open, body, close] = block;
3133
- const lines = body.split("\n");
3134
- const at = lines.findIndex((l) => /^description:/.test(l));
3135
- const line = `description: ${description}`;
3136
- if (at === -1) lines.push(line);
3137
- else lines[at] = line;
3138
-
3139
- fs.writeFileSync(file, raw.replace(open + body + close, open + lines.join("\n") + close));
3171
+ writeWorkspaceFile(tenant, workspace, "AGENTS.md", patchFrontmatter(raw, { description }), {
3172
+ message: "settings: description",
3173
+ ...meta,
3174
+ });
3140
3175
  }
3141
3176
 
3142
3177
  /**
package/src/triggers.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import crypto from "node:crypto";
16
16
  import { platform } from "./platform.ts";
17
17
  import { listFlows, readRun, runVerdict, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
18
- import { getSecret } from "./secrets.ts";
18
+ import { getSecret, signingSecretShown } from "./secrets.ts";
19
19
 
20
20
  /** The last step's result — the same reading runner.ts makes, kept local so
21
21
  * this module never imports the runner (the queue imports both). */
@@ -273,7 +273,7 @@ export function verifyWebhookSignature(
273
273
  if (!flow.signature) return { ok: true, reason: "no signature required" };
274
274
  if (!flow.signingSecret) return { ok: false, reason: `signature: ${flow.signature} needs signing_secret: \${NAME}` };
275
275
  const secret = getSecret(tenant, flow.signingSecret, workspace);
276
- if (!secret) return { ok: false, reason: `signing secret ${flow.signingSecret} is not set` };
276
+ if (!secret) return { ok: false, reason: `signing secret ${signingSecretShown(flow.signingSecret)} is not set` };
277
277
  const hmac = (data: string) => crypto.createHmac("sha256", secret.value).update(data).digest("hex");
278
278
  const fresh = (ts: number) => Math.abs(now / 1000 - ts) <= 300;
279
279
 
package/src/webhook.ts CHANGED
@@ -57,7 +57,10 @@ export function rotateWebhook(tenant: string, workspace: string, flow: string):
57
57
  const hooks = readHooks(tenant, workspace);
58
58
  hooks[flow] = { gen: (hooks[flow]?.gen ?? 0) + 1, rotatedAt: new Date().toISOString() };
59
59
  const file = hooksFile(tenant, workspace);
60
- fs.mkdirSync(path.dirname(file), { recursive: true });
60
+ // Into a workspace that exists, never making one (30 Oct 2026): the mkdir
61
+ // here turned a rotate under a mistyped name into a workspace holding only
62
+ // hooks.json, past the plan's workspace count.
63
+ if (!fs.existsSync(path.dirname(file))) throw Object.assign(new Error(`workspace ${workspace} not found`), { status: 404 });
61
64
  fs.writeFileSync(file, JSON.stringify(hooks, null, 2));
62
65
  return webhookPath(tenant, workspace, flow);
63
66
  }
@@ -93,22 +96,39 @@ export function approveToken(tenant: string, workspace: string, runId: string):
93
96
  .slice(0, 32);
94
97
  }
95
98
 
99
+ /** One parked `wait: event` gate: the step's index in the run record and
100
+ * the moment it parked (the `t` of its "waiting for an external event"
101
+ * trace line). */
102
+ export interface EventGate {
103
+ step: number;
104
+ at: string;
105
+ }
106
+
96
107
  /**
97
108
  * The token that releases a run parked on `wait: event`. Its own HMAC
98
109
  * family, so an approval link cannot double as an event URL or the reverse.
110
+ *
111
+ * With `gate`, the token is for that one park of that one step (30 Oct
112
+ * 2026). The per-run token released every later `wait: event` of the run,
113
+ * so a URL handed to a third party for the first gate also opened the
114
+ * second, and never expired. A gate's token dies with its park: the step
115
+ * released is no longer waiting, and parking again (a loop) stamps a new
116
+ * `at`. The per-run form, without `gate`, is what URLs printed before then
117
+ * carry — the events route still takes it for a step parked under it.
99
118
  */
100
- export function eventToken(tenant: string, workspace: string, runId: string): string {
119
+ export function eventToken(tenant: string, workspace: string, runId: string, gate?: EventGate): string {
101
120
  return crypto
102
121
  .createHmac("sha256", installKey())
103
- .update(`event:${tenant}/${workspace}/${runId}`)
122
+ .update(gate ? `event:${tenant}/${workspace}/${runId}#${gate.step}@${gate.at}` : `event:${tenant}/${workspace}/${runId}`)
104
123
  .digest("hex")
105
124
  .slice(0, 32);
106
125
  }
107
126
 
108
127
  /** The URL a `wait: event` step is released at — absolute when the install
109
- * knows its origin, a path otherwise. */
110
- export function eventUrl(tenant: string, workspace: string, runId: string): string {
111
- const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId)}`;
128
+ * knows its origin, a path otherwise. With `gate`, that step's own URL
129
+ * (`&step=<index>`); the route finds the park's moment on the record. */
130
+ export function eventUrl(tenant: string, workspace: string, runId: string, gate?: EventGate): string {
131
+ const p = `/api/events/${tenant}/${workspace}/${runId}?token=${eventToken(tenant, workspace, runId, gate)}${gate ? `&step=${gate.step}` : ""}`;
112
132
  const base = publicUrl();
113
133
  return base ? `${base}${p}` : p;
114
134
  }