@foldrun/core 8.0.0 → 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/runtime.js +0 -1
- 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/deploy.ts +0 -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 -9
- package/src/runtime.ts +0 -1
- 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/history.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface RevisionSummary {
|
|
|
35
35
|
commit: string | null;
|
|
36
36
|
paths: string[];
|
|
37
37
|
}
|
|
38
|
+
/** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
|
|
39
|
+
* calls this BEFORE it writes: checked only in recordRevision, after the
|
|
40
|
+
* files were replaced, a bad commit left the new source live with no
|
|
41
|
+
* revision, no snapshot and no audit entry (30 Oct 2026). */
|
|
42
|
+
export declare function assertCommitId(commit: string): void;
|
|
38
43
|
/** A local revision id: sortable by time, unique enough, visibly not a sha. */
|
|
39
44
|
export declare function newRevisionId(now?: Date): string;
|
|
40
45
|
/**
|
package/dist/src/history.js
CHANGED
|
@@ -55,6 +55,15 @@ const historyDir = (tenant, scope) => {
|
|
|
55
55
|
assertSafeName(scope, "workspace");
|
|
56
56
|
return path.join(dataRoot(), tenant, ".history", scope);
|
|
57
57
|
};
|
|
58
|
+
/** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
|
|
59
|
+
* calls this BEFORE it writes: checked only in recordRevision, after the
|
|
60
|
+
* files were replaced, a bad commit left the new source live with no
|
|
61
|
+
* revision, no snapshot and no audit entry (30 Oct 2026). */
|
|
62
|
+
export function assertCommitId(commit) {
|
|
63
|
+
if (typeof commit !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(commit)) {
|
|
64
|
+
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 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
58
67
|
/** A local revision id: sortable by time, unique enough, visibly not a sha. */
|
|
59
68
|
export function newRevisionId(now = new Date()) {
|
|
60
69
|
return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
@@ -72,8 +81,17 @@ export function recordRevision(tenant, scope, files, meta = {}) {
|
|
|
72
81
|
return null;
|
|
73
82
|
const dir = historyDir(tenant, scope);
|
|
74
83
|
fs.mkdirSync(dir, { recursive: true });
|
|
84
|
+
// A commit is the revision's id only the first time it is recorded. A
|
|
85
|
+
// second deploy of one commit (a CI re-run after a dashboard edit), or a
|
|
86
|
+
// `commit` that names a revision already here, overwrote that revision's
|
|
87
|
+
// record and its snapshot (30 Oct 2026) — History could no longer restore
|
|
88
|
+
// to it. It gets its own id now; `commit` still says which one it was,
|
|
89
|
+
// and a restore by commit (resolveRevision) still finds it.
|
|
90
|
+
if (meta.commit)
|
|
91
|
+
assertCommitId(meta.commit);
|
|
92
|
+
const commitId = meta.commit && !fs.existsSync(path.join(dir, `${meta.commit}.json`)) ? meta.commit : null;
|
|
75
93
|
const rev = {
|
|
76
|
-
id: meta.id ??
|
|
94
|
+
id: meta.id ?? commitId ?? newRevisionId(),
|
|
77
95
|
at: new Date().toISOString(),
|
|
78
96
|
by: meta.by ?? "system",
|
|
79
97
|
message: meta.message ?? describe(changed),
|
|
@@ -252,7 +270,11 @@ export function resolveRevision(tenant, scope, to, now = Date.now()) {
|
|
|
252
270
|
else {
|
|
253
271
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t))
|
|
254
272
|
throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
255
|
-
|
|
273
|
+
// An exact id first, then a commit: a commit deployed twice is recorded
|
|
274
|
+
// once under its own id and again under a fresh one naming it (30 Oct
|
|
275
|
+
// 2026, recordRevision), and newest-first with one predicate answered
|
|
276
|
+
// the first revision's id with the second's snapshot.
|
|
277
|
+
hit = all.find((r) => r.id === t) ?? all.find((r) => r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
|
|
256
278
|
}
|
|
257
279
|
return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
|
|
258
280
|
}
|
|
@@ -353,8 +375,13 @@ export function changedPathsBetween(tenant, scope, from, to) {
|
|
|
353
375
|
if (from === to)
|
|
354
376
|
return [];
|
|
355
377
|
const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
|
|
356
|
-
|
|
357
|
-
const
|
|
378
|
+
// An exact id before a commit, as resolveRevision.
|
|
379
|
+
const at = (x) => {
|
|
380
|
+
const byId = revs.findIndex((r) => r.id === x);
|
|
381
|
+
return byId >= 0 ? byId : revs.findIndex((r) => r.commit === x);
|
|
382
|
+
};
|
|
383
|
+
const ti = at(to);
|
|
384
|
+
const fi = at(from);
|
|
358
385
|
if (ti < 0 || fi < 0 || ti > fi)
|
|
359
386
|
return null;
|
|
360
387
|
return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare const HOOK_TIMEOUT_DEFAULT: {
|
|
|
6
6
|
};
|
|
7
7
|
export declare const HOOK_TIMEOUT_MAX = 120;
|
|
8
8
|
export declare const HOOK_CODE_MAX: number;
|
|
9
|
+
/** A hook's name: its folder's name, never a path. */
|
|
10
|
+
export declare function isHookName(name: unknown): name is string;
|
|
9
11
|
export interface HookDef {
|
|
10
12
|
name: string;
|
|
11
13
|
/** The folder holding hook.md and run.mjs. */
|
package/dist/src/hooks.js
CHANGED
|
@@ -38,6 +38,10 @@ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 };
|
|
|
38
38
|
export const HOOK_TIMEOUT_MAX = 120;
|
|
39
39
|
export const HOOK_CODE_MAX = 100 * 1024;
|
|
40
40
|
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
41
|
+
/** A hook's name: its folder's name, never a path. */
|
|
42
|
+
export function isHookName(name) {
|
|
43
|
+
return typeof name === "string" && NAME.test(name);
|
|
44
|
+
}
|
|
41
45
|
/** Read hook.md, or say in one sentence why it is not a hook. */
|
|
42
46
|
export function readHookDef(dir, scope) {
|
|
43
47
|
const name = path.basename(dir);
|
|
@@ -117,6 +121,15 @@ export function resolveHooks(args) {
|
|
|
117
121
|
if (seen.has(name))
|
|
118
122
|
continue;
|
|
119
123
|
seen.add(name);
|
|
124
|
+
// Checked before the name is joined to anything (30 Oct 2026): readHookDef
|
|
125
|
+
// tested only basename(dir), so `../../<tenant>/workspaces/<ws>/hooks/<h>`
|
|
126
|
+
// passed, and the hook test route answered which folders exist anywhere
|
|
127
|
+
// on the server — on a laptop it would have run that run.mjs. An error,
|
|
128
|
+
// not missing: a step refuses either way, and the name is the problem.
|
|
129
|
+
if (!isHookName(name)) {
|
|
130
|
+
errors.push(`"${name}" is not a hook name — lowercase letters, digits and dashes (the hook's folder name)`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
120
133
|
const own = path.join(args.workspaceRoot, "hooks", name);
|
|
121
134
|
const shared = path.join(args.libraryRoot, "hooks", name);
|
|
122
135
|
const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
|
package/dist/src/library.js
CHANGED
|
@@ -234,6 +234,10 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
|
|
|
234
234
|
const norm = path.normalize(rel);
|
|
235
235
|
if (norm.startsWith("..") || path.isAbsolute(norm))
|
|
236
236
|
throw new Error(`illegal path: ${rel}`);
|
|
237
|
+
// "." (or "./", "a/..") is the shelf itself: one call took every shared
|
|
238
|
+
// tool or skill from every workspace (30 Oct 2026). Name what goes.
|
|
239
|
+
if (norm === "." || norm === "./")
|
|
240
|
+
throw new Error(`illegal path: ${rel} — name the file or folder to delete, not the whole ${kind} shelf`);
|
|
237
241
|
const target = path.join(libraryDir(tenant, kind), norm);
|
|
238
242
|
const gone = [];
|
|
239
243
|
if (fs.existsSync(target)) {
|
package/dist/src/notify.d.ts
CHANGED
|
@@ -78,10 +78,24 @@ export declare function nextAttemptAt(n: number, at: number): number | null;
|
|
|
78
78
|
* delivery, redeliveries included, so a receiver dedupes on it. */
|
|
79
79
|
export declare function newDeliveryId(): string;
|
|
80
80
|
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
81
|
-
* URL is itself a credential, so it is never echoed resolved.
|
|
81
|
+
* URL is itself a credential, so it is never echoed resolved.
|
|
82
|
+
*
|
|
83
|
+
* A secret bound to sites (`foldrun secrets sites`) is filled only when the
|
|
84
|
+
* resolved URL's host is one of them — the rule the egress proxy and the
|
|
85
|
+
* tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
|
|
86
|
+
* any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
|
|
87
|
+
* and press Test. The host is read AFTER filling, because the usual shape is
|
|
88
|
+
* the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
|
|
89
|
+
* hooks.slack.com). An unbound secret fills as it does for an agent's
|
|
90
|
+
* `secrets:` and a tool test — workspace first, then the account — since
|
|
91
|
+
* binding is how an account says where a value may go. `refused` says why
|
|
92
|
+
* `url` is null when a binding stopped it; `filled` maps each value put
|
|
93
|
+
* into the URL to its name, so an error that echoes the URL can be scrubbed. */
|
|
82
94
|
export declare function webhookTarget(tenant: string, workspace: string, config?: NotifyConfig | null): {
|
|
83
95
|
url: string | null;
|
|
84
96
|
shown: string;
|
|
97
|
+
refused?: string;
|
|
98
|
+
filled: Record<string, string>;
|
|
85
99
|
};
|
|
86
100
|
export interface WebhookAttempt {
|
|
87
101
|
/** 1-based. */
|
package/dist/src/notify.js
CHANGED
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
import crypto from "node:crypto";
|
|
34
34
|
import { readAgentsMd } from "./runner.js";
|
|
35
35
|
import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
|
|
36
|
-
import { getSecret } from "./secrets.js";
|
|
36
|
+
import { getSecret, secretSites } from "./secrets.js";
|
|
37
|
+
import { siteCovers } from "./network.js";
|
|
37
38
|
import { publicUrl } from "./webhook.js";
|
|
38
39
|
import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
|
|
39
40
|
import { noteSecretUse, healthKey } from "./secret-health.js";
|
|
@@ -180,15 +181,80 @@ export function newDeliveryId() {
|
|
|
180
181
|
return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
181
182
|
}
|
|
182
183
|
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
183
|
-
* URL is itself a credential, so it is never echoed resolved.
|
|
184
|
+
* URL is itself a credential, so it is never echoed resolved.
|
|
185
|
+
*
|
|
186
|
+
* A secret bound to sites (`foldrun secrets sites`) is filled only when the
|
|
187
|
+
* resolved URL's host is one of them — the rule the egress proxy and the
|
|
188
|
+
* tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
|
|
189
|
+
* any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
|
|
190
|
+
* and press Test. The host is read AFTER filling, because the usual shape is
|
|
191
|
+
* the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
|
|
192
|
+
* hooks.slack.com). An unbound secret fills as it does for an agent's
|
|
193
|
+
* `secrets:` and a tool test — workspace first, then the account — since
|
|
194
|
+
* binding is how an account says where a value may go. `refused` says why
|
|
195
|
+
* `url` is null when a binding stopped it; `filled` maps each value put
|
|
196
|
+
* into the URL to its name, so an error that echoes the URL can be scrubbed. */
|
|
184
197
|
export function webhookTarget(tenant, workspace, config = notifyConfig(tenant, workspace)) {
|
|
185
198
|
const raw = config?.url ?? "";
|
|
199
|
+
const filled = {};
|
|
200
|
+
// Every name put in, kept apart from `filled` (value → name, for
|
|
201
|
+
// scrubbing): two secrets with the same value would otherwise leave only
|
|
202
|
+
// the last name there, and the first one's binding would go unchecked.
|
|
203
|
+
const used = new Set();
|
|
186
204
|
const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
|
|
187
205
|
const hit = getSecret(tenant, name, workspace);
|
|
206
|
+
if (hit) {
|
|
207
|
+
filled[hit.value] = name;
|
|
208
|
+
used.add(name);
|
|
209
|
+
}
|
|
188
210
|
return hit ? hit.value : whole;
|
|
189
211
|
});
|
|
190
212
|
const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
|
|
191
|
-
|
|
213
|
+
if (!url || url.includes("${"))
|
|
214
|
+
return { url: null, shown, filled: {} };
|
|
215
|
+
const bound = secretSites(tenant, [...used], workspace);
|
|
216
|
+
if (Object.keys(bound).length) {
|
|
217
|
+
let host = "";
|
|
218
|
+
try {
|
|
219
|
+
host = new URL(url).hostname;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Not a URL: no host can be one of its sites.
|
|
223
|
+
}
|
|
224
|
+
const offSite = Object.entries(bound).filter(([, sites]) => !host || !sites.some((site) => siteCovers(site, host))).map(([n]) => n);
|
|
225
|
+
if (offSite.length) {
|
|
226
|
+
return {
|
|
227
|
+
url: null,
|
|
228
|
+
shown,
|
|
229
|
+
filled: {},
|
|
230
|
+
// The host is not echoed: it is read from the FILLED url, so
|
|
231
|
+
// `https://${KEY}.their.server/` would print the key back.
|
|
232
|
+
refused: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than notify.url's host — not sent`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { url, shown, filled };
|
|
237
|
+
}
|
|
238
|
+
/** An error about a request to a resolved notify.url, with every secret value
|
|
239
|
+
* put back as its ${NAME} — a redirect refusal quotes the URL it was sent to,
|
|
240
|
+
* and the delivery log that keeps it is read by every viewer. */
|
|
241
|
+
function scrubTarget(text, filled) {
|
|
242
|
+
let out = text;
|
|
243
|
+
// Longest first, so a value inside another is not half-replaced.
|
|
244
|
+
for (const value of Object.keys(filled).sort((a, b) => b.length - a.length)) {
|
|
245
|
+
if (!value)
|
|
246
|
+
continue;
|
|
247
|
+
const variants = new Set([value]);
|
|
248
|
+
try {
|
|
249
|
+
variants.add(encodeURI(value)).add(encodeURIComponent(value));
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// A lone surrogate cannot be encoded, so it cannot appear encoded.
|
|
253
|
+
}
|
|
254
|
+
for (const v of variants)
|
|
255
|
+
out = out.split(v).join(`\${${filled[value]}}`);
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
192
258
|
}
|
|
193
259
|
/**
|
|
194
260
|
* One attempt at one delivery, against the workspace's notify: as it reads
|
|
@@ -204,10 +270,13 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
204
270
|
if (!config?.url) {
|
|
205
271
|
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
|
|
206
272
|
}
|
|
207
|
-
const { url } = webhookTarget(tenant, workspace, config);
|
|
273
|
+
const { url, refused, filled } = webhookTarget(tenant, workspace, config);
|
|
208
274
|
if (!url) {
|
|
209
|
-
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
|
|
275
|
+
return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: refused ?? "the secret named in notify.url is not in this account's vault" };
|
|
210
276
|
}
|
|
277
|
+
// A URL that carries a secret is a credential: a redirect is followed only
|
|
278
|
+
// within the origin it was sent to, as the tool tester does.
|
|
279
|
+
const carriesSecret = Object.keys(filled).length > 0;
|
|
211
280
|
try {
|
|
212
281
|
const res = await fetchUntrusted(url, {
|
|
213
282
|
method: "POST",
|
|
@@ -222,7 +291,7 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
222
291
|
},
|
|
223
292
|
body: d.body,
|
|
224
293
|
signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
|
|
225
|
-
});
|
|
294
|
+
}, { sameOrigin: carriesSecret });
|
|
226
295
|
const text = (await res.text().catch(() => "")).slice(0, 500);
|
|
227
296
|
return {
|
|
228
297
|
...base,
|
|
@@ -235,7 +304,7 @@ export async function attemptWebhook(tenant, workspace, d) {
|
|
|
235
304
|
}
|
|
236
305
|
catch (err) {
|
|
237
306
|
const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
|
|
238
|
-
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
|
|
307
|
+
return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: scrubTarget(why, filled).slice(0, 500) };
|
|
239
308
|
}
|
|
240
309
|
}
|
|
241
310
|
/**
|
|
@@ -411,9 +480,9 @@ export async function sendTestNotification(tenant, workspace) {
|
|
|
411
480
|
// The destination is echoed back with the secret still unresolved: a
|
|
412
481
|
// Slack webhook URL is itself a credential, and a page that prints it is
|
|
413
482
|
// a page that leaks it into a screenshot.
|
|
414
|
-
const { url, shown } = webhookTarget(tenant, workspace, config);
|
|
483
|
+
const { url, shown, refused } = webhookTarget(tenant, workspace, config);
|
|
415
484
|
if (!url) {
|
|
416
|
-
return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
|
|
485
|
+
return { ok: false, destination: shown, detail: refused ?? `the secret named in notify.url is not in this account's vault` };
|
|
417
486
|
}
|
|
418
487
|
const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
|
|
419
488
|
// One attempt, recorded in the delivery log like any other — and the way
|
|
@@ -554,8 +623,9 @@ export async function sendRunNotification(tenant, workspace, run) {
|
|
|
554
623
|
}
|
|
555
624
|
// ${SECRET} so the Slack URL — itself a credential — can live in the
|
|
556
625
|
// vault instead of in a file that gets committed.
|
|
557
|
-
|
|
558
|
-
|
|
626
|
+
const target = webhookTarget(tenant, workspace, config);
|
|
627
|
+
if (!target.url) {
|
|
628
|
+
console.error(`[foldrun] notify: ${target.refused ?? "secret in URL not set"} for ${tenant}/${workspace}`);
|
|
559
629
|
return false;
|
|
560
630
|
}
|
|
561
631
|
const event = blocked ? "blocked" : run.status;
|
package/dist/src/runner.js
CHANGED
|
@@ -41,7 +41,7 @@ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec,
|
|
|
41
41
|
import { sendPlainNotification } from "./notify.js";
|
|
42
42
|
import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
|
|
43
43
|
import { refNames } from "./refs.js";
|
|
44
|
-
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
|
|
44
|
+
import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, } from "./store.js";
|
|
45
45
|
import { canReadBack } from "./tool-output.js";
|
|
46
46
|
import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
|
|
47
47
|
import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
|
|
@@ -4694,8 +4694,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4694
4694
|
// resuming around.
|
|
4695
4695
|
if (step.status !== "pending")
|
|
4696
4696
|
continue;
|
|
4697
|
-
|
|
4698
|
-
|
|
4697
|
+
// One folder under agents/, never a path (30 Oct 2026): an ad-hoc
|
|
4698
|
+
// run's agent came from the URL, and `../../<tenant>/…` joined here
|
|
4699
|
+
// ran another account's agent. Refused as not found, before the
|
|
4700
|
+
// existence check could answer for a path outside.
|
|
4701
|
+
const aDir = isOneSegment(step.agent) ? path.join(pDir, "agents", step.agent) : null;
|
|
4702
|
+
if (!aDir || !fs.existsSync(path.join(aDir, "agent.md"))) {
|
|
4699
4703
|
step.status = "failed";
|
|
4700
4704
|
step.events.push({
|
|
4701
4705
|
t: new Date().toISOString(),
|
|
@@ -4918,8 +4922,9 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
4918
4922
|
// step of the flow (isSplicedRecord). It gets no delegateJob of its
|
|
4919
4923
|
// own: depth one.
|
|
4920
4924
|
const delegateFor = (parent) => async (agent, job, cancelled) => {
|
|
4921
|
-
|
|
4922
|
-
|
|
4925
|
+
// One folder under agents/, never a path — as the group check above.
|
|
4926
|
+
const dir = isOneSegment(agent) ? path.join(pDir, "agents", agent) : null;
|
|
4927
|
+
if (!dir || !fs.existsSync(path.join(dir, "agent.md")))
|
|
4923
4928
|
return { status: "failed", result: `${agent} is not an agent in this workspace` };
|
|
4924
4929
|
// A stable id on both records: an index into run.steps moves when a
|
|
4925
4930
|
// rescue or a fan-out is spliced in before the child.
|
|
@@ -5335,8 +5340,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
|
|
|
5335
5340
|
const rescueStep = async (step) => {
|
|
5336
5341
|
if (!step.onFail)
|
|
5337
5342
|
return;
|
|
5338
|
-
const rescuerDir = path.join(pDir, "agents", step.onFail);
|
|
5339
|
-
if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
5343
|
+
const rescuerDir = isOneSegment(step.onFail) ? path.join(pDir, "agents", step.onFail) : null;
|
|
5344
|
+
if (!rescuerDir || !fs.existsSync(path.join(rescuerDir, "agent.md"))) {
|
|
5340
5345
|
step.events.push({
|
|
5341
5346
|
t: new Date().toISOString(),
|
|
5342
5347
|
type: "error",
|
package/dist/src/runtime.js
CHANGED
|
@@ -830,7 +830,6 @@ function prepareLanguageRuntime(tenant, spec) {
|
|
|
830
830
|
if (stale)
|
|
831
831
|
log.push(`runtime ${fp}: cached entry unusable (${stale}); rebuilding`);
|
|
832
832
|
const venvPython = path.join(root, "venv", "bin", "python");
|
|
833
|
-
const nodeModules = path.join(root, "node_modules");
|
|
834
833
|
const wantsPy = wantsPython(spec);
|
|
835
834
|
const wantsNd = wantsNode(spec);
|
|
836
835
|
const built = { built: new Date().toISOString() };
|
package/dist/src/secrets.js
CHANGED
|
@@ -238,7 +238,10 @@ export function mutateVaultFile(file, change) {
|
|
|
238
238
|
}
|
|
239
239
|
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
240
240
|
export function setSecret(tenant, name, value, workspace, kind) {
|
|
241
|
-
|
|
241
|
+
// A string, not anything that prints as one: RegExp.test and the vault's
|
|
242
|
+
// keys both coerce, so ["RESEND_API_KEY"] from a JSON body was stored as
|
|
243
|
+
// RESEND_API_KEY past a caller's check on the name (30 Oct 2026).
|
|
244
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name)) {
|
|
242
245
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
243
246
|
}
|
|
244
247
|
if (value.length > 8192)
|
|
@@ -261,6 +264,9 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
261
264
|
* scheme, path or port. Returns the list as stored.
|
|
262
265
|
*/
|
|
263
266
|
export function setSecretSites(tenant, name, sites, workspace) {
|
|
267
|
+
// all[name] would find ["NAME"] as NAME: see setSecret.
|
|
268
|
+
if (typeof name !== "string")
|
|
269
|
+
throw new Error("a secret's name is a string");
|
|
264
270
|
const clean = [...new Set(sites.map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
265
271
|
for (const site of clean) {
|
|
266
272
|
if (!SITE.test(site))
|
|
@@ -961,7 +967,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
961
967
|
...(config.passphrase ? { passphrase: config.passphrase } : {}),
|
|
962
968
|
...(config.export ? { export: true } : {}),
|
|
963
969
|
};
|
|
964
|
-
if (!SECRET_NAME.test(name))
|
|
970
|
+
if (typeof name !== "string" || !SECRET_NAME.test(name))
|
|
965
971
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
966
972
|
const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
|
|
967
973
|
// Written under the vault's own lock, with the binding checked in the same
|
package/dist/src/storage.d.ts
CHANGED
|
@@ -22,6 +22,15 @@ interface Index {
|
|
|
22
22
|
version: 1;
|
|
23
23
|
files: FileRecord[];
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Refuse a workspace that is not there, as a 404 (30 Oct 2026).
|
|
27
|
+
*
|
|
28
|
+
* The store is keyed by workspace *name* and used to be created for any safe
|
|
29
|
+
* name on first write, so an editor got a fresh quota for every name they
|
|
30
|
+
* invented — stores no workspace list shows and no delete reaches. Every
|
|
31
|
+
* write here, and the routes that read, ask this first.
|
|
32
|
+
*/
|
|
33
|
+
export declare function requireWorkspace(tenant: string, workspace: string): void;
|
|
25
34
|
/**
|
|
26
35
|
* Hosted only. On a laptop `storage/` is just a folder in the user's own
|
|
27
36
|
* project — they can see it, open it, and put a PDF in it with Finder — and
|
|
@@ -57,6 +66,18 @@ export interface Driver {
|
|
|
57
66
|
put(key: string, body: Buffer, mime: string): Promise<void>;
|
|
58
67
|
get(key: string): Promise<Buffer>;
|
|
59
68
|
has(key: string): Promise<boolean>;
|
|
69
|
+
/** The object's real length, or null when there is none — what the store
|
|
70
|
+
* holds, never what a browser said it sent (30 Oct 2026). */
|
|
71
|
+
size(key: string): Promise<number | null>;
|
|
72
|
+
/** The bytes as a stream, optionally one inclusive byte range — so a route
|
|
73
|
+
* hands a file on without holding all of it in memory. */
|
|
74
|
+
open(key: string, range?: {
|
|
75
|
+
start: number;
|
|
76
|
+
end: number;
|
|
77
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
78
|
+
/** Server-side copy to another key (s3 only — the fs driver's blobs move
|
|
79
|
+
* with their directory). Used when a workspace is renamed. */
|
|
80
|
+
copy?(from: string, to: string): Promise<void>;
|
|
60
81
|
remove(key: string): Promise<void>;
|
|
61
82
|
/** A URL a browser can GET directly, or null when the driver has no such
|
|
62
83
|
* thing (fs) and the route must stream the bytes itself. */
|
|
@@ -65,7 +86,9 @@ export interface Driver {
|
|
|
65
86
|
presignGet(key: string, filename: string, mime: string, ttlSec?: number, inlineType?: string): Promise<string | null>;
|
|
66
87
|
/** A URL a browser can PUT directly to, so upload bytes never transit the
|
|
67
88
|
* dashboard process. Null on fs, where the route takes the body. */
|
|
68
|
-
|
|
89
|
+
/** `size`: sign the length in, so the store refuses a PUT of any other
|
|
90
|
+
* length (30 Oct 2026) — the per-file limit then holds at the bucket. */
|
|
91
|
+
presignPut(key: string, mime: string, ttlSec?: number, size?: number): Promise<string | null>;
|
|
69
92
|
}
|
|
70
93
|
/** Where the fs driver keeps a blob. Exported because the run mirror
|
|
71
94
|
* hardlinks straight to it rather than reading and rewriting the bytes. */
|
|
@@ -101,9 +124,33 @@ export declare function contentDisposition(type: "inline" | "attachment", filena
|
|
|
101
124
|
export declare function driverFor(tenant: string, workspace: string): Driver;
|
|
102
125
|
export declare function putFile(tenant: string, workspace: string, rel: string, body: Buffer, by: string): Promise<FileRecord>;
|
|
103
126
|
/** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
|
|
104
|
-
* putFile, minus the bytes — the platform never saw them.
|
|
105
|
-
|
|
127
|
+
* putFile, minus the bytes — the platform never saw them.
|
|
128
|
+
*
|
|
129
|
+
* `_claimed` is the size the browser says it sent, kept for the callers that
|
|
130
|
+
* pass it and never believed (30 Oct 2026): the record, the per-file limit
|
|
131
|
+
* and the quota all use the length the store reports. It used to store the
|
|
132
|
+
* claim, so an object registered as 0 bytes (or -1e12) sat outside every
|
|
133
|
+
* limit and lowered the quota line for everything after it. */
|
|
134
|
+
export declare function registerUpload(tenant: string, workspace: string, rel: string, sha: string, _claimed: number, by: string): Promise<FileRecord>;
|
|
106
135
|
export declare function readFileBytes(tenant: string, workspace: string, rel: string): Promise<Buffer | null>;
|
|
136
|
+
/** A stored file's real length, from the store — a stat or a HEAD, never a
|
|
137
|
+
* read. Null when there is no such record or no object behind it. */
|
|
138
|
+
export declare function storedSize(tenant: string, workspace: string, rel: string): Promise<number | null>;
|
|
139
|
+
/**
|
|
140
|
+
* A stored file as a stream, with its real length — for a route that passes
|
|
141
|
+
* the bytes on (a download, a public link) rather than reading them whole
|
|
142
|
+
* into memory. Null when there is no such record or no object behind it.
|
|
143
|
+
* `range` is inclusive and must lie inside the file (storedSize first, then
|
|
144
|
+
* byteRange, when the caller honours a Range header).
|
|
145
|
+
*/
|
|
146
|
+
export declare function openFile(tenant: string, workspace: string, rel: string, range?: {
|
|
147
|
+
start: number;
|
|
148
|
+
end: number;
|
|
149
|
+
} | null): Promise<{
|
|
150
|
+
record: FileRecord;
|
|
151
|
+
size: number;
|
|
152
|
+
body: ReadableStream<Uint8Array>;
|
|
153
|
+
} | null>;
|
|
107
154
|
export declare function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean>;
|
|
108
155
|
/** A link the browser can follow, or null when the route must stream it. */
|
|
109
156
|
export declare function downloadUrl(tenant: string, workspace: string, record: FileRecord, opts?: {
|
|
@@ -111,7 +158,32 @@ export declare function downloadUrl(tenant: string, workspace: string, record: F
|
|
|
111
158
|
}): Promise<string | null>;
|
|
112
159
|
export declare function previewType(rel: string): string | null;
|
|
113
160
|
/** A link the browser can PUT to, plus the key it will land at. Null on fs. */
|
|
114
|
-
export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string): Promise<string | null>;
|
|
161
|
+
export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string, size?: number): Promise<string | null>;
|
|
162
|
+
/**
|
|
163
|
+
* Drop everything a workspace stored: every object its index names, then the
|
|
164
|
+
* index (30 Oct 2026). Called when a workspace is deleted — the store is kept
|
|
165
|
+
* by name beside the folder, so it used to outlive it: the files stayed
|
|
166
|
+
* downloadable under the old name, public links kept serving them, and a new
|
|
167
|
+
* workspace given the same name inherited them. Best effort per object (a
|
|
168
|
+
* leaked object costs storage; the index going is what makes them
|
|
169
|
+
* unreachable), and a no-op when there is no store.
|
|
170
|
+
*/
|
|
171
|
+
export declare function purgeWorkspaceStorage(tenant: string, workspace: string): Promise<void>;
|
|
172
|
+
/**
|
|
173
|
+
* Move a workspace's stored files to its new name (30 Oct 2026). A rename
|
|
174
|
+
* used to move the folder only: the renamed workspace lost its files, and
|
|
175
|
+
* the old name kept them for whoever took it next.
|
|
176
|
+
*
|
|
177
|
+
* On fs the blobs live under the store's directory, so moving the directory
|
|
178
|
+
* is the whole move. On s3 an object's key carries the workspace name, so
|
|
179
|
+
* every object is copied first; only once all copies landed does the index
|
|
180
|
+
* move and the old objects go. A copy that fails leaves the store under the
|
|
181
|
+
* old name, whole, and throws — the caller can put the folder back.
|
|
182
|
+
*
|
|
183
|
+
* Whatever a store under the new name held is dropped first: it belonged to a
|
|
184
|
+
* workspace of that name that no longer exists, never to this one.
|
|
185
|
+
*/
|
|
186
|
+
export declare function moveWorkspaceStorage(tenant: string, from: string, to: string): Promise<void>;
|
|
115
187
|
/**
|
|
116
188
|
* Put the workspace's files on disk under `<workspace>/storage/`, where a run
|
|
117
189
|
* copies them into its container like any other directory.
|