@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/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/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
  }