@foldrun/core 0.6.0 → 1.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/README.md +14 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +10 -0
- package/dist/src/run-container.js +72 -14
- package/dist/src/runner.d.ts +205 -0
- package/dist/src/runner.js +273 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +38 -4
- package/dist/src/step-exec.d.ts +21 -2
- package/dist/src/step-exec.js +165 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +31 -1
- package/dist/src/store.js +90 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +18 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/hooks.ts +406 -0
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/notify.ts +11 -1
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/providers.ts +27 -1
- package/src/run-container.ts +75 -14
- package/src/runner.ts +279 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +44 -4
- package/src/step-exec.ts +171 -11
- package/src/storage.ts +13 -0
- package/src/store.ts +89 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/README.md
CHANGED
|
@@ -222,9 +222,22 @@ allow products built on its Agent SDK to offer claude.ai login unless
|
|
|
222
222
|
Anthropic has approved them, so check that the terms cover your use before
|
|
223
223
|
you rely on a login token.
|
|
224
224
|
|
|
225
|
+
## Versions
|
|
226
|
+
|
|
227
|
+
[Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html): a patch is
|
|
228
|
+
a bug fix, a minor adds something and breaks nothing, a major breaks
|
|
229
|
+
something and says what. The promise covers the **public API**, the entry
|
|
230
|
+
points listed in `package.json` under `foldrun.publicApi`: `@foldrun/core`
|
|
231
|
+
itself and the subpaths the `foldrun` CLI imports. Every other module under
|
|
232
|
+
`@foldrun/core/*` can be reached, because the runner image imports its own
|
|
233
|
+
copy by name, but it is internal and may change in any release. Deprecated
|
|
234
|
+
names say so in their doc comment for at least one minor before a major
|
|
235
|
+
removes them. Pre-releases are on npm's `next` tag. How a release decides
|
|
236
|
+
its number: [CONTRIBUTING.md](CONTRIBUTING.md#versions).
|
|
237
|
+
|
|
225
238
|
## Status
|
|
226
239
|
|
|
227
|
-
|
|
240
|
+
Local and hosted runs work. Hosted runs go through a durable on-disk
|
|
228
241
|
queue (survives restarts, parks at approval gates), the dashboard has
|
|
229
242
|
accounts and sessions (scrypt + signed cookies; first signup is free, then
|
|
230
243
|
`FOLDRUN_OPEN_SIGNUP=1`), spending lands in an append-only per-account
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export * from "./src/linkable.ts";
|
|
|
22
22
|
export * from "./src/refs.ts";
|
|
23
23
|
export * from "./src/tool-names.ts";
|
|
24
24
|
export * from "./src/history.ts";
|
|
25
|
+
export * from "./src/zip.ts";
|
|
26
|
+
export * from "./src/package.ts";
|
|
25
27
|
export * from "./src/gitrepo.ts";
|
|
26
28
|
export * from "./src/storage.ts";
|
|
27
29
|
export * from "./src/secrets.ts";
|
|
@@ -31,6 +33,7 @@ export * from "./src/notify.ts";
|
|
|
31
33
|
export * from "./src/webhook.ts";
|
|
32
34
|
export * from "./src/approvals.ts";
|
|
33
35
|
export * from "./src/deploy.ts";
|
|
36
|
+
export * from "./src/agent-import.ts";
|
|
34
37
|
export * from "./src/account.ts";
|
|
35
38
|
export * from "./src/tar.ts";
|
|
36
39
|
export * from "./src/git.ts";
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,8 @@ export * from "./src/linkable.js";
|
|
|
33
33
|
export * from "./src/refs.js";
|
|
34
34
|
export * from "./src/tool-names.js";
|
|
35
35
|
export * from "./src/history.js";
|
|
36
|
+
export * from "./src/zip.js";
|
|
37
|
+
export * from "./src/package.js";
|
|
36
38
|
export * from "./src/gitrepo.js";
|
|
37
39
|
export * from "./src/storage.js";
|
|
38
40
|
export * from "./src/secrets.js";
|
|
@@ -42,6 +44,7 @@ export * from "./src/notify.js";
|
|
|
42
44
|
export * from "./src/webhook.js";
|
|
43
45
|
export * from "./src/approvals.js";
|
|
44
46
|
export * from "./src/deploy.js";
|
|
47
|
+
export * from "./src/agent-import.js";
|
|
45
48
|
export * from "./src/account.js";
|
|
46
49
|
export * from "./src/tar.js";
|
|
47
50
|
export * from "./src/git.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface AgentImportResult {
|
|
2
|
+
/** The name the agent has in the target workspace. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** Workspace-relative paths written. */
|
|
5
|
+
files: string[];
|
|
6
|
+
/** What the copy refers to that the target workspace does not have. The
|
|
7
|
+
* import still happens: a missing secret is set in a minute, and refusing
|
|
8
|
+
* would make the person copy the file by hand instead. */
|
|
9
|
+
warnings: string[];
|
|
10
|
+
}
|
|
11
|
+
/** An error the API layer turns into a status rather than a 500. */
|
|
12
|
+
export declare class AgentImportError extends Error {
|
|
13
|
+
readonly status: number;
|
|
14
|
+
constructor(message: string, status: number);
|
|
15
|
+
}
|
|
16
|
+
/** The files `importAgent` would copy, without writing anything. */
|
|
17
|
+
export declare function agentSourceFiles(tenant: string, from: string, agent: string): string[];
|
|
18
|
+
export declare function importAgent(tenant: string, from: string, agent: string, to: string, opts?: {
|
|
19
|
+
as?: string;
|
|
20
|
+
by?: string;
|
|
21
|
+
}): AgentImportResult;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Bring an agent you already have into another workspace of the same account.
|
|
2
|
+
//
|
|
3
|
+
// A new workspace starts blank (blankWorkspaceFiles), and the agents worth
|
|
4
|
+
// having in it are usually ones already written and tuned elsewhere — a
|
|
5
|
+
// researcher from one desk, a reviewer from another. Copying them by hand
|
|
6
|
+
// meant opening each file, and missing the skills and scripts that live in
|
|
7
|
+
// the agent's own folder. This copies the folder: what the agent IS, never
|
|
8
|
+
// what it has accumulated.
|
|
9
|
+
//
|
|
10
|
+
// Copied: agents/<name>/agent.md and everything authored beside it — its own
|
|
11
|
+
// skills/, scripts/, knowledge/. Not copied: memory/ (what it learned in the
|
|
12
|
+
// other workspace's runs), outputs/, state/, the step's workspace link, and
|
|
13
|
+
// the SDK's .claude session files. A fresh copy starts with no lessons that
|
|
14
|
+
// were about somebody else's desk.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import matter from "gray-matter";
|
|
18
|
+
import { assertSafeName, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, workspaceDir, workspaceTools, listWorkspaces, } from "./store.js";
|
|
19
|
+
import { libraryDir, libraryTools } from "./library.js";
|
|
20
|
+
import { isRuntimeTool, toolEntryName } from "./tool-names.js";
|
|
21
|
+
import { discoverSkills } from "./runner.js";
|
|
22
|
+
import { listSecrets } from "./secrets.js";
|
|
23
|
+
/** An error the API layer turns into a status rather than a 500. */
|
|
24
|
+
export class AgentImportError extends Error {
|
|
25
|
+
status;
|
|
26
|
+
constructor(message, status) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Accumulated or generated, not authored — never carried to another workspace. */
|
|
32
|
+
const NOT_COPIED = /^agents\/[^/]+\/(memory|outputs|state|workspace|\.claude)(\/|$)|^agents\/[^/]+\/\.claude\.json$/;
|
|
33
|
+
/** The files `importAgent` would copy, without writing anything. */
|
|
34
|
+
export function agentSourceFiles(tenant, from, agent) {
|
|
35
|
+
const prefix = `agents/${agent}/`;
|
|
36
|
+
return listWorkspaceFiles(tenant, from).filter((p) => p.startsWith(prefix) && !NOT_COPIED.test(p));
|
|
37
|
+
}
|
|
38
|
+
const list = (v) => Array.isArray(v) ? v.map((x) => toolEntryName(x)).filter(Boolean) : typeof v === "string" && v.trim() ? [v.trim()] : [];
|
|
39
|
+
export function importAgent(tenant, from, agent, to, opts = {}) {
|
|
40
|
+
assertSafeName(from, "workspace");
|
|
41
|
+
assertSafeName(to, "workspace");
|
|
42
|
+
assertSafeName(agent, "agent");
|
|
43
|
+
const name = opts.as?.trim() || agent;
|
|
44
|
+
assertSafeName(name, "agent");
|
|
45
|
+
const known = new Set(listWorkspaces(tenant).map((w) => w.name));
|
|
46
|
+
if (!known.has(from))
|
|
47
|
+
throw new AgentImportError(`no workspace called "${from}" in this account`, 404);
|
|
48
|
+
if (!known.has(to))
|
|
49
|
+
throw new AgentImportError(`no workspace called "${to}" in this account`, 404);
|
|
50
|
+
if (from === to && name === agent) {
|
|
51
|
+
throw new AgentImportError(`${agent} is already in ${to} — give the copy another name with "as"`, 409);
|
|
52
|
+
}
|
|
53
|
+
const files = agentSourceFiles(tenant, from, agent);
|
|
54
|
+
if (!files.includes(`agents/${agent}/agent.md`)) {
|
|
55
|
+
throw new AgentImportError(`no agent called "${agent}" in ${from}`, 404);
|
|
56
|
+
}
|
|
57
|
+
if (fs.existsSync(path.join(workspaceDir(tenant, to), "agents", name))) {
|
|
58
|
+
throw new AgentImportError(`${to} already has an agent called "${name}" — import it under another name with "as"`, 409);
|
|
59
|
+
}
|
|
60
|
+
const written = [];
|
|
61
|
+
const message = `import agents/${agent} from ${from}${name !== agent ? ` as ${name}` : ""}`;
|
|
62
|
+
let front = {};
|
|
63
|
+
for (const src of files) {
|
|
64
|
+
let content = readWorkspaceFile(tenant, from, src);
|
|
65
|
+
const rel = src.slice(`agents/${agent}/`.length);
|
|
66
|
+
if (rel === "agent.md") {
|
|
67
|
+
front = matter(content).data;
|
|
68
|
+
// The folder is the name flows address; a `name:` that disagrees with
|
|
69
|
+
// it is the first thing check would complain about.
|
|
70
|
+
if (name !== agent)
|
|
71
|
+
content = content.replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`);
|
|
72
|
+
}
|
|
73
|
+
const dest = `agents/${name}/${rel}`;
|
|
74
|
+
writeWorkspaceFile(tenant, to, dest, content, { ...(opts.by ? { by: opts.by } : {}), message });
|
|
75
|
+
written.push(dest);
|
|
76
|
+
}
|
|
77
|
+
return { name, files: written, warnings: missingFor(tenant, to, name, front) };
|
|
78
|
+
}
|
|
79
|
+
/** What the imported agent names that the target workspace cannot provide. */
|
|
80
|
+
function missingFor(tenant, to, name, front) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const ws = workspaceDir(tenant, to);
|
|
83
|
+
const tools = { ...libraryTools(tenant), ...workspaceTools(tenant, to) };
|
|
84
|
+
const noTool = list(front.tools).filter((t) => !isRuntimeTool(t) && !tools[t]);
|
|
85
|
+
if (noTool.length)
|
|
86
|
+
out.push(`tools not in ${to} or the account library: ${noTool.join(", ")} — import or add them under tools/`);
|
|
87
|
+
const skills = new Set();
|
|
88
|
+
for (const [base, sub] of [
|
|
89
|
+
[path.join(ws, "agents", name), "skills"],
|
|
90
|
+
[ws, "skills"],
|
|
91
|
+
[ws, ".agents/skills"],
|
|
92
|
+
[libraryDir(tenant), "skills"],
|
|
93
|
+
]) {
|
|
94
|
+
for (const s of discoverSkills(base, sub))
|
|
95
|
+
skills.add(s.name);
|
|
96
|
+
}
|
|
97
|
+
const noSkill = list(front.skills).filter((s) => !skills.has(s));
|
|
98
|
+
if (noSkill.length)
|
|
99
|
+
out.push(`skills not in ${to} or the account library: ${noSkill.join(", ")}`);
|
|
100
|
+
const scripts = list(front.scripts).filter((s) => !fs.existsSync(path.join(ws, "agents", name, "scripts", s)) &&
|
|
101
|
+
!fs.existsSync(path.join(ws, "scripts", s)) &&
|
|
102
|
+
!fs.existsSync(path.join(libraryDir(tenant), "scripts", s)));
|
|
103
|
+
if (scripts.length)
|
|
104
|
+
out.push(`scripts not in ${to}: ${scripts.join(", ")} — copy them into scripts/`);
|
|
105
|
+
let have;
|
|
106
|
+
try {
|
|
107
|
+
have = new Set(listSecrets(tenant, to).map((s) => s.name));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
have = new Set();
|
|
111
|
+
}
|
|
112
|
+
const noSecret = list(front.secrets).filter((s) => !have.has(s));
|
|
113
|
+
if (noSecret.length)
|
|
114
|
+
out.push(`secrets not set for ${to}: ${noSecret.join(", ")} — add them with \`foldrun secrets set\` or Settings → Secrets`);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
package/dist/src/api-tools.js
CHANGED
|
@@ -240,7 +240,8 @@ function genericTool(api, env, bucket, log) {
|
|
|
240
240
|
`Allowed methods: ${api.methods.join(", ")}. Provide only the path after the base URL. ` +
|
|
241
241
|
`Authentication is added automatically — never ask the user for credentials.` +
|
|
242
242
|
typed +
|
|
243
|
-
rateSentence(api)
|
|
243
|
+
rateSentence(api) +
|
|
244
|
+
(api.guide ? `\n\nHow to use it (from its tool.md):\n${api.guide}` : ""), {
|
|
244
245
|
method: z.enum(api.methods).describe("HTTP method"),
|
|
245
246
|
path: z.string().describe("Path appended to the base URL, e.g. /customers/123/campaigns"),
|
|
246
247
|
query: z.record(z.string(), z.string()).optional().describe("Query parameters"),
|
|
@@ -291,6 +292,14 @@ resolved) {
|
|
|
291
292
|
`so no tool is exposed. Report it as blocked; do not reach this API another way.`);
|
|
292
293
|
continue;
|
|
293
294
|
}
|
|
295
|
+
// A base still holding `${FOLDRUN_API_URL}` was not filled: this install
|
|
296
|
+
// has no public address configured (a laptop, usually). Say so rather
|
|
297
|
+
// than hand the model a URL that cannot resolve.
|
|
298
|
+
if (/\$\{[A-Z_]+\}/.test(api.base)) {
|
|
299
|
+
promptLines.push(`- **${api.name}** — unavailable this run: its base \`${api.base}\` names a variable this ` +
|
|
300
|
+
`install does not set (FOLDRUN_API_URL or FOLDRUN_PUBLIC_URL). Report it as blocked.`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
294
303
|
const typedNames = [];
|
|
295
304
|
for (const op of ops) {
|
|
296
305
|
tools.push(typedTool(api, op, env, bucket, log));
|
|
@@ -311,7 +320,10 @@ resolved) {
|
|
|
311
320
|
promptLines.push(`- **${api.name}** — ${api.description} Base URL \`${api.base}\`. ` +
|
|
312
321
|
`${ops.length} typed tool${ops.length === 1 ? "" : "s"}` +
|
|
313
322
|
(withGeneric ? `, plus \`call_${api.name}\` for anything they do not cover` : "") +
|
|
314
|
-
`. Credentials are injected for you.${rateLine}`, ...listed, ...more
|
|
323
|
+
`. Credentials are injected for you.${rateLine}`, ...listed, ...more,
|
|
324
|
+
// Typed-only (an `operations:` allowlist) has no generic tool whose
|
|
325
|
+
// description could carry the guide, so it rides here.
|
|
326
|
+
...(!withGeneric && api.guide ? [` How to use it (from its tool.md):\n${api.guide}`] : []));
|
|
315
327
|
}
|
|
316
328
|
else {
|
|
317
329
|
promptLines.push(`- **${api.name}** — call with the \`call_${api.name}\` tool. ${api.description} ` +
|
package/dist/src/approvals.d.ts
CHANGED
|
@@ -135,8 +135,15 @@ export declare function expireStaleGate(tenant: string, workspace: string, run:
|
|
|
135
135
|
*
|
|
136
136
|
* Only steps waiting on an event are released: a run that is also asking a
|
|
137
137
|
* person a question keeps asking.
|
|
138
|
+
*
|
|
139
|
+
* `by` names who delivered it when that is known — a signed-in person or a
|
|
140
|
+
* key through the dashboard's approve route — and `step` releases one
|
|
141
|
+
* waiting step rather than all of them.
|
|
138
142
|
*/
|
|
139
|
-
export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string
|
|
143
|
+
export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string, opts?: {
|
|
144
|
+
by?: string;
|
|
145
|
+
step?: number;
|
|
146
|
+
}): Promise<{
|
|
140
147
|
run: RunRecord;
|
|
141
148
|
steps: number[];
|
|
142
149
|
}>;
|
package/dist/src/approvals.js
CHANGED
|
@@ -271,22 +271,27 @@ export async function expireStaleGate(tenant, workspace, run, now = Date.now())
|
|
|
271
271
|
*
|
|
272
272
|
* Only steps waiting on an event are released: a run that is also asking a
|
|
273
273
|
* person a question keeps asking.
|
|
274
|
+
*
|
|
275
|
+
* `by` names who delivered it when that is known — a signed-in person or a
|
|
276
|
+
* key through the dashboard's approve route — and `step` releases one
|
|
277
|
+
* waiting step rather than all of them.
|
|
274
278
|
*/
|
|
275
|
-
export async function deliverEvent(tenant, workspace, runId, payload) {
|
|
279
|
+
export async function deliverEvent(tenant, workspace, runId, payload, opts = {}) {
|
|
276
280
|
const run = readRun(tenant, workspace, runId);
|
|
277
281
|
if (!run)
|
|
278
282
|
throw new ApprovalError("run not found", 404);
|
|
279
283
|
const waiting = run.steps
|
|
280
284
|
.map((s, i) => ({ s, i }))
|
|
281
|
-
.filter(({ s }) => s.status === "awaiting-approval" && s.waitFor === "event");
|
|
282
|
-
if (waiting.length === 0)
|
|
283
|
-
throw new ApprovalError("this run is not waiting for an event"
|
|
285
|
+
.filter(({ s, i }) => s.status === "awaiting-approval" && s.waitFor === "event" && (opts.step === undefined || opts.step === i));
|
|
286
|
+
if (waiting.length === 0) {
|
|
287
|
+
throw new ApprovalError(opts.step === undefined ? "this run is not waiting for an event" : `step ${opts.step} is not waiting for an event`, 409);
|
|
288
|
+
}
|
|
284
289
|
for (const { s } of waiting)
|
|
285
290
|
s.eventPayload = payload.slice(0, 20_000);
|
|
286
291
|
writeRun(tenant, workspace, run);
|
|
287
292
|
let decided = [];
|
|
288
293
|
for (const { i } of waiting) {
|
|
289
|
-
const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: "by an external event" });
|
|
294
|
+
const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: opts.by ?? "by an external event" });
|
|
290
295
|
decided = decided.concat(out.steps);
|
|
291
296
|
}
|
|
292
297
|
return { run: readRun(tenant, workspace, runId) ?? run, steps: decided };
|
package/dist/src/completions.js
CHANGED
|
@@ -36,6 +36,7 @@ const TOOL_GROUPS = [
|
|
|
36
36
|
{ label: "code", hint: "Bash — runs anything in the sandbox" },
|
|
37
37
|
{ label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
|
|
38
38
|
{ label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
|
|
39
|
+
{ label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
|
|
39
40
|
];
|
|
40
41
|
const SDK_TOOL_NAMES = [
|
|
41
42
|
{ label: "Read", hint: "exact SDK name" },
|
package/dist/src/confine.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export declare function isWithin(root: string, abs: string): boolean;
|
|
|
36
36
|
export declare function expandVirtual(raw: string, roots: Roots): {
|
|
37
37
|
abs: string;
|
|
38
38
|
readOnly: boolean;
|
|
39
|
+
prefix: string;
|
|
39
40
|
} | null;
|
|
40
41
|
export declare function checkPaths(toolName: string, input: Record<string, unknown>, roots: Roots): ConfineVerdict;
|
|
41
42
|
export declare function checkBash(command: string): ConfineVerdict;
|
package/dist/src/confine.js
CHANGED
|
@@ -176,11 +176,22 @@ export function expandVirtual(raw, roots) {
|
|
|
176
176
|
const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
|
|
177
177
|
for (const v of VIRTUAL) {
|
|
178
178
|
if (candidate.startsWith(v.prefix)) {
|
|
179
|
-
return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
|
|
179
|
+
return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly, prefix: v.prefix };
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
return null;
|
|
183
183
|
}
|
|
184
|
+
/** Whether `<agentDir>/workspace` is the step's link to the workspace root
|
|
185
|
+
* (linkWorkspace), so a `workspace/…` path opens the right file as written. */
|
|
186
|
+
function linkedWorkspace(roots) {
|
|
187
|
+
const link = path.join(roots.agentDir, "workspace");
|
|
188
|
+
try {
|
|
189
|
+
return fs.lstatSync(link).isSymbolicLink() && real(link) === real(path.resolve(roots.workspaceRoot));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
184
195
|
export function checkPaths(toolName, input, roots) {
|
|
185
196
|
const workspace = real(path.resolve(roots.workspaceRoot));
|
|
186
197
|
const library = real(path.resolve(roots.libraryRoot));
|
|
@@ -264,9 +275,16 @@ export function checkPaths(toolName, input, roots) {
|
|
|
264
275
|
};
|
|
265
276
|
}
|
|
266
277
|
// Hand the real path back to the tool, so the virtual prefix the prompt
|
|
267
|
-
// advertises is one the agent can actually open
|
|
268
|
-
|
|
278
|
+
// advertises is one the agent can actually open — unless the step's
|
|
279
|
+
// `workspace` link already opens it. Then the path is left as written,
|
|
280
|
+
// because the SDK remembers what was Read under the path the tool ran
|
|
281
|
+
// with but checks an Edit against the one the model wrote, before this
|
|
282
|
+
// hook sees it: Read workspace/x (rewritten) then Edit workspace/x was
|
|
283
|
+
// refused "File has not been read yet" on every try, and blog-desk's
|
|
284
|
+
// fact-checker spent a pass on it (run-muuhguk3-vhrg, 2026-10-05).
|
|
285
|
+
if (virtual && !(virtual.prefix === "workspace/" && linkedWorkspace(roots))) {
|
|
269
286
|
rewritten = { ...(rewritten ?? input), [key]: abs };
|
|
287
|
+
}
|
|
270
288
|
}
|
|
271
289
|
// Glob searches from `path` (the cwd when absent) and its PATTERN never
|
|
272
290
|
// climbs: `../../storage/draft/*.md` answered "No files found" with the
|
|
@@ -350,6 +368,16 @@ const PROTECTED_BASH = [
|
|
|
350
368
|
"instead.",
|
|
351
369
|
},
|
|
352
370
|
];
|
|
371
|
+
/**
|
|
372
|
+
* A web address, which names a place on a server rather than in the workspace.
|
|
373
|
+
*
|
|
374
|
+
* The protected-path patterns match a bare `runs/`, so an agent calling the
|
|
375
|
+
* platform's own API — `curl https://…/api/workspaces/x/runs/<id>` — was told
|
|
376
|
+
* it was reading the run journal. The weekly retros read their week through
|
|
377
|
+
* that API and were refused on 9 Oct 2026. Only http(s): a `file://` URL is a
|
|
378
|
+
* path, and stays checked.
|
|
379
|
+
*/
|
|
380
|
+
const WEB_URL = /\bhttps?:\/\/[^\s'"`)]+/gi;
|
|
353
381
|
export function checkBash(command) {
|
|
354
382
|
for (const re of BASH_ESCAPES) {
|
|
355
383
|
if (re.test(command)) {
|
|
@@ -366,10 +394,11 @@ export function checkBash(command) {
|
|
|
366
394
|
if (!segment.trim())
|
|
367
395
|
continue;
|
|
368
396
|
const writes = WRITES_TO_A_FILE.test(segment);
|
|
397
|
+
const paths = segment.replace(WEB_URL, " ");
|
|
369
398
|
for (const rule of PROTECTED_BASH) {
|
|
370
399
|
if (rule.writeOnly && !writes)
|
|
371
400
|
continue;
|
|
372
|
-
if (rule.re.test(
|
|
401
|
+
if (rule.re.test(paths))
|
|
373
402
|
return { ok: false, reason: `Bash was denied: ${rule.reason}` };
|
|
374
403
|
}
|
|
375
404
|
}
|
package/dist/src/deploy.d.ts
CHANGED
|
@@ -60,6 +60,15 @@ export declare function readTree(dir: string): DeployFile[];
|
|
|
60
60
|
* only the deployed files, so a library or gallery outward tool is not seen
|
|
61
61
|
* here — `foldrun check`, which resolves every scope, remains the real gate.
|
|
62
62
|
* Non-blocking by decision: the deploy proceeds and says so. */
|
|
63
|
+
/**
|
|
64
|
+
* A frontmatter block that lost its opening `---`: `key: value` lines, then a
|
|
65
|
+
* lone `---`, with nothing above them. Nothing is read from it — the parser
|
|
66
|
+
* sees prose — so the agent runs with no tools and the default model, and a
|
|
67
|
+
* model told to call a tool it was never given writes the call out as text.
|
|
68
|
+
* seo-digest's editor did that every Sunday from 20 Sep to 5 Oct 2026.
|
|
69
|
+
* Returns the problem, or null.
|
|
70
|
+
*/
|
|
71
|
+
export declare function unopenedFrontmatter(raw: string): string | null;
|
|
63
72
|
export declare function deployWarnings(files: DeployFile[]): DeployIssue[];
|
|
64
73
|
export declare function deployIssues(files: DeployFile[]): DeployIssue[];
|
|
65
74
|
/**
|
package/dist/src/deploy.js
CHANGED
|
@@ -164,6 +164,27 @@ export function readTree(dir) {
|
|
|
164
164
|
* only the deployed files, so a library or gallery outward tool is not seen
|
|
165
165
|
* here — `foldrun check`, which resolves every scope, remains the real gate.
|
|
166
166
|
* Non-blocking by decision: the deploy proceeds and says so. */
|
|
167
|
+
/**
|
|
168
|
+
* A frontmatter block that lost its opening `---`: `key: value` lines, then a
|
|
169
|
+
* lone `---`, with nothing above them. Nothing is read from it — the parser
|
|
170
|
+
* sees prose — so the agent runs with no tools and the default model, and a
|
|
171
|
+
* model told to call a tool it was never given writes the call out as text.
|
|
172
|
+
* seo-digest's editor did that every Sunday from 20 Sep to 5 Oct 2026.
|
|
173
|
+
* Returns the problem, or null.
|
|
174
|
+
*/
|
|
175
|
+
export function unopenedFrontmatter(raw) {
|
|
176
|
+
const text = raw.replace(/^\uFEFF/, "");
|
|
177
|
+
if (/^---\r?\n/.test(text))
|
|
178
|
+
return null;
|
|
179
|
+
const lines = text.split(/\r?\n/);
|
|
180
|
+
const close = lines.findIndex((l) => l.trim() === "---");
|
|
181
|
+
if (close < 1)
|
|
182
|
+
return null;
|
|
183
|
+
const above = lines.slice(0, close).filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
184
|
+
if (!above.length || !above.every((l) => /^[A-Za-z_][\w-]*:(\s|$)/.test(l) || /^\s+\S/.test(l)))
|
|
185
|
+
return null;
|
|
186
|
+
return "the frontmatter has no opening `---`, so none of it is read: the agent runs with no tools and the default model. Add `---` as the first line.";
|
|
187
|
+
}
|
|
167
188
|
export function deployWarnings(files) {
|
|
168
189
|
const front = (content) => {
|
|
169
190
|
try {
|
|
@@ -190,6 +211,9 @@ export function deployWarnings(files) {
|
|
|
190
211
|
const outwardAgents = [];
|
|
191
212
|
const agentNames = [];
|
|
192
213
|
const out = [];
|
|
214
|
+
if (!files.some((f) => /^agents\/[^/]+\/agent\.md$/.test(f.path)) && !files.some((f) => /^\.(claude|agents)\/agents\/[^/]+\.md$/.test(f.path))) {
|
|
215
|
+
out.push({ where: "agents/", message: "no agents yet — add one (agents/<name>/agent.md), or import one from another workspace" });
|
|
216
|
+
}
|
|
193
217
|
// The web: warnings check prints — the old session: key, and a pinned
|
|
194
218
|
// user agent behind the runner image's Chrome, which the platform knows
|
|
195
219
|
// from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
|
|
@@ -257,6 +281,9 @@ export function deployIssues(files) {
|
|
|
257
281
|
for (const f of files) {
|
|
258
282
|
if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path))
|
|
259
283
|
continue;
|
|
284
|
+
const unopened = unopenedFrontmatter(f.content);
|
|
285
|
+
if (unopened)
|
|
286
|
+
at(f.path, unopened);
|
|
260
287
|
let front = {};
|
|
261
288
|
try {
|
|
262
289
|
front = matter(f.content).data;
|
|
@@ -285,9 +312,10 @@ export function deployIssues(files) {
|
|
|
285
312
|
for (const w of localeProblems(front))
|
|
286
313
|
at(f.path, w);
|
|
287
314
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
315
|
+
// No agents is a workspace that has not started yet — "Create workspace"
|
|
316
|
+
// makes exactly that — and deploying it is harmless: a flow that names an
|
|
317
|
+
// agent this push lacks is refused below, step by step. deployWarnings says
|
|
318
|
+
// it, so the empty state is visible without blocking the first push.
|
|
291
319
|
// Flows, parsed from the incoming text rather than from disk. A step naming
|
|
292
320
|
// an agent that this push does not ship is the single most common way a
|
|
293
321
|
// deploy breaks a schedule, and it is free to catch here.
|
package/dist/src/evals.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// Deterministic checks run first and cost nothing. The judge only runs if
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
|
+
import { accountModelEnv } from "./model-env.js";
|
|
33
34
|
import { sdkEnv } from "./step-exec.js";
|
|
34
35
|
import fs from "node:fs";
|
|
35
36
|
import path from "node:path";
|
|
@@ -353,7 +354,7 @@ function shell(command, cwd) {
|
|
|
353
354
|
// The judge gets the rubric and the output, and must answer with one word.
|
|
354
355
|
// Constrained output beats asking for a score: "PASS" or "FAIL" can't be
|
|
355
356
|
// misread, where "7/10" needs a threshold nobody agreed on.
|
|
356
|
-
async function judge(rubric, output, model, effort) {
|
|
357
|
+
async function judge(rubric, output, model, effort, env) {
|
|
357
358
|
const prompt = `You are grading one piece of output against one criterion. Answer with exactly ` +
|
|
358
359
|
`PASS or FAIL on the first line, then one short sentence of reason.\n\n` +
|
|
359
360
|
`Criterion: ${rubric}\n\n<output>\n${output.slice(0, 20000)}\n</output>`;
|
|
@@ -367,7 +368,7 @@ async function judge(rubric, output, model, effort) {
|
|
|
367
368
|
allowedTools: [],
|
|
368
369
|
settingSources: [],
|
|
369
370
|
strictMcpConfig: true,
|
|
370
|
-
env
|
|
371
|
+
env,
|
|
371
372
|
maxTurns: 1,
|
|
372
373
|
},
|
|
373
374
|
});
|
|
@@ -381,7 +382,7 @@ async function judge(rubric, output, model, effort) {
|
|
|
381
382
|
}
|
|
382
383
|
return text.trim();
|
|
383
384
|
}
|
|
384
|
-
async function checkAssertion(assertion, output, agentDir, model, effort) {
|
|
385
|
+
async function checkAssertion(assertion, output, agentDir, model, effort, judgeEnv = () => sdkEnv(undefined)) {
|
|
385
386
|
const hay = output.toLowerCase();
|
|
386
387
|
switch (assertion.type) {
|
|
387
388
|
case "contains": {
|
|
@@ -414,7 +415,16 @@ async function checkAssertion(assertion, output, agentDir, model, effort) {
|
|
|
414
415
|
return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
|
|
415
416
|
}
|
|
416
417
|
case "judge": {
|
|
417
|
-
|
|
418
|
+
// The judge runs on the account's own model key, never quietly on the
|
|
419
|
+
// platform's: a customer's eval is not the operator's bill.
|
|
420
|
+
let env;
|
|
421
|
+
try {
|
|
422
|
+
env = judgeEnv();
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
426
|
+
}
|
|
427
|
+
const verdict = await judge(assertion.value, output, model, effort, env);
|
|
418
428
|
const passed = /^\s*PASS\b/i.test(verdict);
|
|
419
429
|
return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
|
|
420
430
|
}
|
|
@@ -485,8 +495,15 @@ export async function runEval(tenant, workspace, info) {
|
|
|
485
495
|
for (const a of cheap)
|
|
486
496
|
assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
487
497
|
if (assertions.every((r) => r.passed)) {
|
|
498
|
+
const judgeEnv = () => {
|
|
499
|
+
const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
|
|
500
|
+
if (m.format !== "anthropic") {
|
|
501
|
+
throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
|
|
502
|
+
}
|
|
503
|
+
return m.env;
|
|
504
|
+
};
|
|
488
505
|
for (const a of judges)
|
|
489
|
-
assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
|
|
506
|
+
assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
|
|
490
507
|
}
|
|
491
508
|
else {
|
|
492
509
|
for (const a of judges) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export declare const HOOK_EVENTS: readonly ["tool.call", "tool.result", "step.start", "step.end"];
|
|
2
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
3
|
+
export declare const HOOK_TIMEOUT_DEFAULT: {
|
|
4
|
+
readonly gate: 30;
|
|
5
|
+
readonly watch: 60;
|
|
6
|
+
};
|
|
7
|
+
export declare const HOOK_TIMEOUT_MAX = 120;
|
|
8
|
+
export declare const HOOK_CODE_MAX: number;
|
|
9
|
+
export interface HookDef {
|
|
10
|
+
name: string;
|
|
11
|
+
/** The folder holding hook.md and run.mjs. */
|
|
12
|
+
dir: string;
|
|
13
|
+
scope: "workspace" | "account";
|
|
14
|
+
on: HookEvent;
|
|
15
|
+
/** `tool:` — which tools a tool.call / tool.result hook sees. Unset is all. */
|
|
16
|
+
tool?: string;
|
|
17
|
+
timeoutSec: number;
|
|
18
|
+
description?: string;
|
|
19
|
+
/** run.mjs as it was when the step started. The hook runs from this copy,
|
|
20
|
+
* so an agent that edits the file mid-step changes nothing that runs. */
|
|
21
|
+
code: string;
|
|
22
|
+
}
|
|
23
|
+
/** Read hook.md, or say in one sentence why it is not a hook. */
|
|
24
|
+
export declare function readHookDef(dir: string, scope: HookDef["scope"]): HookDef | {
|
|
25
|
+
error: string;
|
|
26
|
+
};
|
|
27
|
+
/** Every hook folder in a hooks/ directory. */
|
|
28
|
+
export declare function listHookDirs(hooksDir: string): string[];
|
|
29
|
+
/** The names a frontmatter `hooks:` holds — a list, or one name. */
|
|
30
|
+
export declare function hookNames(raw: unknown): string[];
|
|
31
|
+
/**
|
|
32
|
+
* The hooks one agent runs, in order: what the account turned on, then the
|
|
33
|
+
* workspace, then the agent itself, each name once. A name resolves to the
|
|
34
|
+
* workspace's own hooks/ first, then the library's. Names that resolve to
|
|
35
|
+
* nothing come back in `missing` — `foldrun check` errors on them, and a run
|
|
36
|
+
* refuses to start the step rather than run unguarded.
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveHooks(args: {
|
|
39
|
+
workspaceRoot: string;
|
|
40
|
+
libraryRoot: string;
|
|
41
|
+
names: string[];
|
|
42
|
+
}): {
|
|
43
|
+
hooks: HookDef[];
|
|
44
|
+
missing: string[];
|
|
45
|
+
errors: string[];
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Does `pattern` name this tool? Alternatives split on `|`, `*` matches any
|
|
49
|
+
* run of characters. A pattern without `__` also matches a tool's short
|
|
50
|
+
* name, so `web` catches `mcp__foldrun_scripts__web` and `Bash` catches Bash.
|
|
51
|
+
*/
|
|
52
|
+
export declare function matchesTool(pattern: string | undefined, tool: string): boolean;
|
|
53
|
+
/** What one hook said, normalised. */
|
|
54
|
+
export interface HookAnswer {
|
|
55
|
+
ok: boolean;
|
|
56
|
+
/** tool.call */
|
|
57
|
+
decision?: "allow" | "deny";
|
|
58
|
+
reason?: string;
|
|
59
|
+
input?: Record<string, unknown>;
|
|
60
|
+
/** step.start */
|
|
61
|
+
context?: string;
|
|
62
|
+
/** step.end */
|
|
63
|
+
reply?: string;
|
|
64
|
+
/** tool.result / anything: a line for the trail */
|
|
65
|
+
note?: string;
|
|
66
|
+
/** Why it failed — threw, timed out, wrong shape. */
|
|
67
|
+
failure?: string;
|
|
68
|
+
ms: number;
|
|
69
|
+
}
|
|
70
|
+
export declare function hookEnv(from: Record<string, string | undefined>, hook: HookDef): Record<string, string>;
|
|
71
|
+
/** Run one hook on one event. Never throws: a failure is an answer. */
|
|
72
|
+
export declare function runHook(hook: HookDef, event: Record<string, unknown>, opts: {
|
|
73
|
+
cwd: string;
|
|
74
|
+
env: Record<string, string | undefined>;
|
|
75
|
+
node?: string;
|
|
76
|
+
}): Promise<HookAnswer>;
|
|
77
|
+
/** The last JSON object a hook printed, checked against its event's shape. */
|
|
78
|
+
export declare function parseAnswer(on: HookEvent, stdout: string): Omit<HookAnswer, "ms">;
|
|
79
|
+
/** What a run of tool.call hooks decided for one call. */
|
|
80
|
+
export interface ToolCallVerdict {
|
|
81
|
+
decision: "allow" | "deny";
|
|
82
|
+
reason?: string;
|
|
83
|
+
/** The input after every rewrite, when any hook rewrote it. */
|
|
84
|
+
input?: Record<string, unknown>;
|
|
85
|
+
/** One line per hook that did something, for the trail. */
|
|
86
|
+
records: HookRecord[];
|
|
87
|
+
}
|
|
88
|
+
/** One hook's effect, as the trail keeps it. */
|
|
89
|
+
export interface HookRecord {
|
|
90
|
+
hook: string;
|
|
91
|
+
event: HookEvent;
|
|
92
|
+
/** deny | rewrite | context | reply | note | error */
|
|
93
|
+
did: "deny" | "rewrite" | "context" | "reply" | "note" | "error";
|
|
94
|
+
tool?: string;
|
|
95
|
+
reason?: string;
|
|
96
|
+
ms: number;
|
|
97
|
+
}
|
|
98
|
+
/** A hook record as it crosses from the sandbox: checked, never trusted. */
|
|
99
|
+
export declare function isHookRecord(x: unknown): x is HookRecord;
|
|
100
|
+
export declare function isGate(on: HookEvent): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* tool.call: every matching hook in order. The first deny ends it — the most
|
|
103
|
+
* restrictive answer wins; a rewrite is handed on to the next hook, so they
|
|
104
|
+
* chain in the order they were turned on. A hook that fails denies.
|
|
105
|
+
*/
|
|
106
|
+
export declare function runToolCallHooks(hooks: HookDef[], call: {
|
|
107
|
+
tool: string;
|
|
108
|
+
input: Record<string, unknown>;
|
|
109
|
+
base: Record<string, unknown>;
|
|
110
|
+
}, run: (hook: HookDef, event: Record<string, unknown>) => Promise<HookAnswer>): Promise<ToolCallVerdict>;
|
|
111
|
+
/** The trail's line for a record. */
|
|
112
|
+
export declare function hookLine(r: HookRecord): string;
|
|
113
|
+
/** For `foldrun check`: every problem with the hooks/ folder of one root. */
|
|
114
|
+
export declare function checkHooksDir(hooksDir: string, scope: HookDef["scope"]): string[];
|