@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -9,8 +9,125 @@
9
9
  // or a run approved from a phone would resume differently from one approved
10
10
  // at a desk. So the decision lives in core and the routes only say who made
11
11
  // it.
12
- import { readRun, writeRun } from "./store.js";
12
+ import crypto from "node:crypto";
13
+ import { readRun, writeRun, readFlow, mayApprove } from "./store.js";
13
14
  import { platform } from "./platform.js";
15
+ import { approveToken, installKey } from "./webhook.js";
16
+ // ----------------------------------------------------------------- links
17
+ /** How long an emailed link decides for when the flow does not say. */
18
+ export const APPROVE_LINK_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
19
+ /** The link's lifetime: the flow's `approve_within:` when it has one —
20
+ * after that the gate has expired on its own — else a week. */
21
+ export function approveLinkTtlMs(flow) {
22
+ return flow?.approveWithin ? flow.approveWithin * 1000 : APPROVE_LINK_DEFAULT_TTL_MS;
23
+ }
24
+ const b64 = (s) => Buffer.from(s, "utf8").toString("base64url");
25
+ const unb64 = (s) => Buffer.from(s, "base64url").toString("utf8");
26
+ function signLink(tenant, workspace, runId, step, expires, approver) {
27
+ return crypto
28
+ .createHmac("sha256", installKey())
29
+ .update(`approve-link:${tenant}/${workspace}/${runId}/${step}/${expires}/${approver}`)
30
+ .digest("hex")
31
+ .slice(0, 32);
32
+ }
33
+ /**
34
+ * The token an emailed link carries. Bound to one step of one run and to
35
+ * a moment, and — when the notification went to a named approver — to
36
+ * that person, so the link decides as them. A link that was only bound
37
+ * to the run approved every later gate of that run for as long as it ran,
38
+ * and a forwarded one let anyone past `approvers:`.
39
+ *
40
+ * `step.expires.approver.signature`; the signature covers all of it plus
41
+ * the run's identity, under the install key, so rotating the key kills
42
+ * every link at once, the way it kills every hook.
43
+ */
44
+ export function approveLinkToken(tenant, workspace, runId, link) {
45
+ const approver = (link.approver ?? "").trim().toLowerCase();
46
+ const expires = Math.floor(link.expiresAt);
47
+ return `${link.step}.${expires}.${approver ? b64(approver) : "-"}.${signLink(tenant, workspace, runId, link.step, expires, approver)}`;
48
+ }
49
+ /** The path an emailed link opens — absolute when the caller prefixes the
50
+ * install's public origin. `decision=reject` pre-selects; a GET never
51
+ * decides. */
52
+ export function approveLinkPath(tenant, workspace, runId, link) {
53
+ return `/api/approve/${tenant}/${workspace}/${runId}?token=${approveLinkToken(tenant, workspace, runId, link)}`;
54
+ }
55
+ const same = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
56
+ /**
57
+ * What a presented token is, or why it is nothing. 401 for a token that
58
+ * was never minted for this run, 410 for one that was and has expired —
59
+ * the second deserves a sentence, because the person holding it did
60
+ * nothing wrong.
61
+ */
62
+ export function readApproveLink(tenant, workspace, runId, presented, now = Date.now()) {
63
+ const invalid = { error: "invalid token", status: 401 };
64
+ if (!presented)
65
+ return invalid;
66
+ const parts = presented.split(".");
67
+ if (parts.length === 4) {
68
+ const [stepText, expText, approverText, sig] = parts;
69
+ if (!/^\d+$/.test(stepText) || !/^\d+$/.test(expText))
70
+ return invalid;
71
+ let approver = "";
72
+ try {
73
+ approver = approverText === "-" ? "" : unb64(approverText);
74
+ }
75
+ catch {
76
+ return invalid;
77
+ }
78
+ const step = Number(stepText);
79
+ const expires = Number(expText);
80
+ if (!same(sig, signLink(tenant, workspace, runId, step, expires, approver)))
81
+ return invalid;
82
+ if (now >= expires)
83
+ return { error: "this link has expired — open the run in the dashboard to decide it", status: 410 };
84
+ return { link: { kind: "step", step, expiresAt: new Date(expires).toISOString(), approver: approver || null } };
85
+ }
86
+ if (same(presented, approveToken(tenant, workspace, runId)))
87
+ return { link: { kind: "run" } };
88
+ return invalid;
89
+ }
90
+ /** A used link is written on the run as a digest, never the token. */
91
+ const linkDigest = (token) => crypto.createHash("sha256").update(token).digest("hex").slice(0, 24);
92
+ /**
93
+ * Everything a link has to pass before it decides anything, answered the
94
+ * same way for the page that shows the gate and the POST that decides it.
95
+ * Returns which waiting steps the link may decide and who it decides as.
96
+ */
97
+ export function checkApproveLink(tenant, workspace, run, presented, now = Date.now()) {
98
+ const read = readApproveLink(tenant, workspace, run.id, presented, now);
99
+ if ("error" in read)
100
+ return { ok: false, status: read.status, message: read.error };
101
+ const { link } = read;
102
+ if (run.approvalLinksUsed?.includes(linkDigest(presented))) {
103
+ return { ok: false, status: 409, message: "this link was already used — a link decides once" };
104
+ }
105
+ // The older, run-bound link: kept for runs already waiting, but not for
106
+ // longer than a new link would have been.
107
+ if (link.kind === "run") {
108
+ const since = Date.parse(run.parkedAt ?? run.startedAt);
109
+ if (Number.isFinite(since) && now - since > APPROVE_LINK_DEFAULT_TTL_MS) {
110
+ return { ok: false, status: 410, message: "this link has expired — open the run in the dashboard to decide it" };
111
+ }
112
+ }
113
+ const waiting = run.steps
114
+ .map((s, i) => ({ s, i }))
115
+ .filter(({ s, i }) => s.status === "awaiting-approval" && s.waitFor !== "event" && (link.kind === "run" || i === link.step))
116
+ .map(({ i }) => i);
117
+ const approver = link.kind === "step" ? link.approver : null;
118
+ // A flow that names its approvers is asking for a person; a link is a
119
+ // possession. Only a link minted for one of the named approvers carries
120
+ // enough of an identity to be checked against the list.
121
+ const flow = readFlow(tenant, workspace, run.flow);
122
+ if (flow?.approvers?.length && !approver) {
123
+ return {
124
+ ok: false,
125
+ status: 403,
126
+ message: `this flow names who may approve it (${flow.approvers.join(", ")}), and an emailed link carries no identity — sign in to the dashboard to decide it`,
127
+ };
128
+ }
129
+ return { ok: true, link, steps: waiting, approver };
130
+ }
14
131
  /** An error the HTTP layer can map straight to a status — see errorResponse. */
15
132
  class ApprovalError extends Error {
16
133
  status;
@@ -32,9 +149,19 @@ export async function decideApproval(tenant, workspace, runId, d) {
32
149
  const run = readRun(tenant, workspace, runId);
33
150
  if (!run)
34
151
  throw new ApprovalError("run not found", 404);
152
+ // A link decides what it was minted for, as whom it was minted for.
153
+ let allowed = null;
154
+ if (d.link !== undefined) {
155
+ const check = checkApproveLink(tenant, workspace, run, d.link);
156
+ if (!check.ok)
157
+ throw new ApprovalError(check.message, check.status);
158
+ allowed = check.steps;
159
+ d = { ...d, actor: check.approver ? { email: check.approver, role: null } : null };
160
+ }
161
+ assertMayDecide(tenant, workspace, run, d);
35
162
  const waiting = run.steps
36
163
  .map((s, i) => ({ s, i }))
37
- .filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i));
164
+ .filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i) && (allowed === null || allowed.includes(i)));
38
165
  if (waiting.length === 0)
39
166
  throw new ApprovalError("nothing is awaiting approval", 409);
40
167
  const note = typeof d.note === "string" ? d.note.trim() : "";
@@ -62,6 +189,17 @@ export async function decideApproval(tenant, workspace, runId, d) {
62
189
  : `rejected ${d.by}${reason ? `: ${reason.slice(0, 200)}` : ""}`,
63
190
  });
64
191
  }
192
+ // The deadline belonged to the gate that just closed. Leaving it on the
193
+ // record would hand a later gate in the same run a deadline that has
194
+ // already passed.
195
+ if (run.steps.every((s) => s.status !== "awaiting-approval"))
196
+ run.approveBy = null;
197
+ // A link decides once. Written as a digest on the record, so a step that
198
+ // parks again — a loop, a rerun from the gate — is not decided by the
199
+ // same forwarded mail.
200
+ if (d.link !== undefined) {
201
+ run.approvalLinksUsed = [...(run.approvalLinksUsed ?? []), linkDigest(d.link)].slice(-50);
202
+ }
65
203
  writeRun(tenant, workspace, run);
66
204
  // A parked run has no process polling for this decision — the worker
67
205
  // that was driving it gave its slot back at the gate. Line it up again.
@@ -72,6 +210,58 @@ export async function decideApproval(tenant, workspace, runId, d) {
72
210
  }
73
211
  return { run, steps: waiting.map(({ i }) => i) };
74
212
  }
213
+ /**
214
+ * The two rules a person's decision has to pass. A token-bearing door — the
215
+ * emailed link, an external event — carries no actor and is not checked
216
+ * here: possession of a token derived from the run is its own authority,
217
+ * and the link was mailed to whoever the account told us to ask.
218
+ *
219
+ * Throws 403, which the routes surface verbatim: a refusal that does not say
220
+ * which rule refused sends someone to the wrong settings page.
221
+ */
222
+ function assertMayDecide(tenant, workspace, run, d) {
223
+ if (!d.actor)
224
+ return;
225
+ const email = (d.actor.email ?? "").trim().toLowerCase();
226
+ // Rejecting your own run is always allowed: stopping something you started
227
+ // needs no second opinion, and the gate's whole purpose is that it does
228
+ // not proceed. Only approval needs someone else.
229
+ if (d.decision === "approve" && email && run.startedBy && run.startedBy === email) {
230
+ throw new ApprovalError("you started this run — an approval gate asks a second person, so someone else has to answer it", 403);
231
+ }
232
+ const flow = readFlow(tenant, workspace, run.flow);
233
+ if (!mayApprove(flow, d.actor)) {
234
+ throw new ApprovalError(`this flow's approvers: list does not include you (${flow?.approvers?.join(", ")})`, 403);
235
+ }
236
+ }
237
+ /**
238
+ * A gate that ran out of time. `approve_within:` in the flow file stamps a
239
+ * deadline when the run parks; past it, the gate is rejected and the run
240
+ * fails, exactly as if a person had said no.
241
+ *
242
+ * Rejection, not approval, is the only safe way for a clock to answer a
243
+ * question a person was asked. The whole reason the step is parked is that
244
+ * someone wanted to look at it first.
245
+ *
246
+ * Returns whether it acted. Called from the same sweep that closes abandoned
247
+ * runs, so an expiry needs nothing running of its own.
248
+ */
249
+ export async function expireStaleGate(tenant, workspace, run, now = Date.now()) {
250
+ if (run.status !== "awaiting-approval" || !run.approveBy)
251
+ return false;
252
+ if (now < new Date(run.approveBy).getTime())
253
+ return false;
254
+ // A step waiting on `wait: event` is not waiting on a person and is not
255
+ // this deadline's business.
256
+ if (!run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"))
257
+ return false;
258
+ await decideApproval(tenant, workspace, run.id, {
259
+ decision: "reject",
260
+ by: "automatically — nobody answered in time",
261
+ reason: `no decision within the flow's approve_within: (due ${run.approveBy})`,
262
+ });
263
+ return true;
264
+ }
75
265
  /**
76
266
  * Release a run parked on `wait: event` with what arrived. The payload is
77
267
  * stored on the step — not as an approval note, which is a person's
@@ -0,0 +1,129 @@
1
+ /** Set by the platform on a step it put on the slim image to browse
2
+ * through the pod. The web tool then never launches a browser of its own. */
3
+ export declare const POD_ONLY_ENV = "FOLDRUN_BROWSER_POD_ONLY";
4
+ /** Where the web tool appends one JSON line per reconnect, and the line
5
+ * that says the pod is lost or the call needs the full image. */
6
+ export declare const POD_EVENTS_ENV = "FOLDRUN_BROWSER_POD_EVENTS";
7
+ /** The file, inside the step's sandbox. */
8
+ export declare const POD_EVENTS_FILE = "/tmp/foldrun-browser-pod.jsonl";
9
+ /** The note the run carries when the fallback was taken. Worded once. */
10
+ export declare const RERAN_ON_FULL = "browser pod lost; re-ran on full";
11
+ /**
12
+ * Browse actions that can change something beyond the page in front of the
13
+ * model: anything that types, picks, submits, signs in, uploads, runs script
14
+ * or pays. A click is here because a click can submit and nothing before
15
+ * the click can tell. The web tool's run.mjs carries a copy (it decides
16
+ * whether a dropped call may be repeated); the gallery test holds the two
17
+ * equal.
18
+ */
19
+ export declare const BROWSE_WRITE_ACTIONS: readonly ["click", "dblclick", "rightclick", "tap", "swipe", "mouse", "drag", "fill", "type", "insert", "paste", "press", "keyboard", "keydown", "keyup", "clear", "select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve"];
20
+ export type CallKind = {
21
+ write: false;
22
+ } | {
23
+ write: true;
24
+ what: string;
25
+ };
26
+ export interface ClassifyContext {
27
+ /** SDK tool name → HTTP method, for an API's typed operations (api-tools
28
+ * builds the map; a generic call_<api> carries its method in the input). */
29
+ methods?: Record<string, string>;
30
+ /** Web actions a paid provider answers this step — from the env's
31
+ * FOLDRUN_WEB_<ACTION>_VIA. */
32
+ paidWeb?: Record<string, string>;
33
+ }
34
+ /** The browse actions a call asked for, nested `if` branches included. */
35
+ export declare function browseActionNames(actions: unknown): string[];
36
+ /** What a browse call's arguments would write, or null when it only reads. */
37
+ export declare function browseWrites(input: Record<string, unknown>): string | null;
38
+ /**
39
+ * Whether one tool call can have changed anything outside the model's head,
40
+ * and if so, a few words saying what. Pure.
41
+ */
42
+ export declare function classifyCall(tool: string, input: Record<string, unknown> | undefined, ctx?: ClassifyContext): CallKind;
43
+ /** Paid web actions, from a step's env (FOLDRUN_WEB_<ACTION>_VIA=provider). */
44
+ export declare function paidWebActions(env: Record<string, string | undefined>): Record<string, string>;
45
+ /** One line of the web tool's pod log. */
46
+ export interface PodEvent {
47
+ kind: "reconnect" | "lost" | "needs-full";
48
+ /** reconnect: which try, of how many, and whether it got through. */
49
+ attempt?: number;
50
+ of?: number;
51
+ ok?: boolean;
52
+ /** lost: whether the call ran on the pod at all before it was lost — a
53
+ * call that never connected changed nothing, whatever it asked for. */
54
+ ran?: boolean;
55
+ error?: string;
56
+ why?: string;
57
+ /** When the web tool wrote the line (ISO). */
58
+ at?: string;
59
+ }
60
+ /** ", at 10:15:15Z" from a pod log line's `at`; empty when it has none. */
61
+ export declare function podAt(at: unknown): string;
62
+ /** Lines of the pod log from byte `offset` on, and where the next read
63
+ * starts. A torn last line waits for the next read. */
64
+ export declare function readPodEvents(text: string, offset: number): {
65
+ events: PodEvent[];
66
+ next: number;
67
+ };
68
+ /** What crosses back from a step that browsed through the pod. */
69
+ export interface BrowserPodOutcome {
70
+ /** Reconnect tries the web tool made, and how many got through: the pod
71
+ * answered and the call went on, rather than dropping again at once. */
72
+ reconnects: number;
73
+ reconnected: number;
74
+ /** Reconnects that reached the pod, after which the call dropped again at
75
+ * once — a pod still answering while it shut down. Never "got through". */
76
+ closedAgain?: number;
77
+ /** Set when the step stopped because the pod was gone (or a call needed
78
+ * a browser in the step, which slim does not have). */
79
+ lost?: {
80
+ cause: "lost" | "needs-full";
81
+ detail: string;
82
+ };
83
+ /** What the step had written by then, worded — empty means reads only. */
84
+ writes?: string[];
85
+ }
86
+ /** "a.md (Write), crm POST and 2 more". */
87
+ export declare function describeWrites(writes: string[]): string;
88
+ /** The decision, once the pod is gone: re-run on full, or fail and say why. */
89
+ export declare function podLossDecision(pod: BrowserPodOutcome): {
90
+ rerun: true;
91
+ note: string;
92
+ } | {
93
+ rerun: false;
94
+ message: string;
95
+ };
96
+ /**
97
+ * Why a browsing step must have a browser in its own sandbox, from what the
98
+ * step start can see (the agent's `web.browse` block, as env), or null when
99
+ * the account's pod can serve every call it could make by default. A call
100
+ * that picks an in-step engine itself is caught at the call (needs-full).
101
+ */
102
+ export declare function slimBrowseBlocker(env: Record<string, string | undefined>): string | null;
103
+ /** The outcome as it crosses back from the sandbox, checked field by field
104
+ * (the done line is the pod's word); null when absent or not this shape. */
105
+ export declare function parseBrowserPod(v: unknown): BrowserPodOutcome | null;
106
+ /** One line for a person: "2 reconnects (2 got through)", "browser pod
107
+ * lost; re-ran on full", or the failure. What the run page, `foldrun
108
+ * report` and an attempt row say. A pod that was lost in the end is never
109
+ * said to have let a reconnect through — "1 reconnect (1 got through); pod
110
+ * lost" read live as a contradiction — and a reconnect that reached the pod
111
+ * only for the call to drop again says that. */
112
+ export declare function browserPodLine(p: BrowserPodOutcome & {
113
+ fallback?: string;
114
+ failure?: string;
115
+ }): string;
116
+ /** What podTriesLine reads of a StepAttempt (store.ts) — structural, so
117
+ * this module stays free of the store's imports for the CLI and the web. */
118
+ export interface TryRow {
119
+ n: number;
120
+ status: string;
121
+ image?: string;
122
+ costUsd: number | null;
123
+ }
124
+ /** A step's tries in one line when the browser pod was lost under one of
125
+ * them — "slim · lost · $0.0040 → full · completed · $0.0060" — so the run
126
+ * page and `foldrun report` show the slim go and the full re-run apart.
127
+ * `#n` leads each try when the step had more than one attempt. Null when
128
+ * no try was lost. */
129
+ export declare function podTriesLine(tries: readonly TryRow[] | null | undefined): string | null;
@@ -0,0 +1,284 @@
1
+ // Slim browsing: a step whose only reason for the full runner image is that
2
+ // it browses may run on the slim image and drive the account's browser pod
3
+ // over Playwright's protocol. The pod can go away mid-step — a node drained,
4
+ // the idle reaper, an OOM — and a slim step has no browser of its own to fall
5
+ // back on. So:
6
+ //
7
+ // 1. The web tool reconnects first: a few tries, short backoff (the tool's
8
+ // run.mjs; each try is an event in FOLDRUN_BROWSER_POD_EVENTS).
9
+ // 2. Still gone: the step stops, and what it has done so far decides.
10
+ // Only reads — re-run the whole step from the start on the full image,
11
+ // and say so on the run ("browser pod lost; re-ran on full").
12
+ // 3. Anything written — fail the step: "browser pod died after the step had
13
+ // written <what>". Doing it again could send, post or charge twice, so
14
+ // retry:, on-fail: or a person decides.
15
+ //
16
+ // Read or write is decided from the calls the step made, as the model asked
17
+ // for them (step-exec.ts keeps the ledger). When a call could be either it
18
+ // counts as a write: the cost of being wrong that way is a failed step a
19
+ // retry can redo; the other way it is a duplicate email.
20
+ //
21
+ // Steps that act outward (a tool marked `outward: true`) never run slim; nor
22
+ // does anything the step start can already see needs a browser in the step
23
+ // (lightpanda, obscura, live sessions, extensions) — slimBrowseBlocker.
24
+ /** Set by the platform on a step it put on the slim image to browse
25
+ * through the pod. The web tool then never launches a browser of its own. */
26
+ export const POD_ONLY_ENV = "FOLDRUN_BROWSER_POD_ONLY";
27
+ /** Where the web tool appends one JSON line per reconnect, and the line
28
+ * that says the pod is lost or the call needs the full image. */
29
+ export const POD_EVENTS_ENV = "FOLDRUN_BROWSER_POD_EVENTS";
30
+ /** The file, inside the step's sandbox. */
31
+ export const POD_EVENTS_FILE = "/tmp/foldrun-browser-pod.jsonl";
32
+ /** The note the run carries when the fallback was taken. Worded once. */
33
+ export const RERAN_ON_FULL = "browser pod lost; re-ran on full";
34
+ /**
35
+ * Browse actions that can change something beyond the page in front of the
36
+ * model: anything that types, picks, submits, signs in, uploads, runs script
37
+ * or pays. A click is here because a click can submit and nothing before
38
+ * the click can tell. The web tool's run.mjs carries a copy (it decides
39
+ * whether a dropped call may be repeated); the gallery test holds the two
40
+ * equal.
41
+ */
42
+ export const BROWSE_WRITE_ACTIONS = [
43
+ "click", "dblclick", "rightclick", "tap", "swipe", "mouse", "drag",
44
+ "fill", "type", "insert", "paste", "press", "keyboard", "keydown", "keyup", "clear",
45
+ "select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve",
46
+ ];
47
+ /** Call arguments of web action=browse that are writes whatever the actions:
48
+ * page script, a paid captcha solver, a confirmation of a credential step. */
49
+ const BROWSE_WRITE_ARGS = ["js", "captcha", "captcha_solver", "confirm"];
50
+ /** Web actions that only read — when foldrun's own answers them. A paid
51
+ * provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
52
+ * it twice is paying twice. `monitor` keeps state, so it always writes. */
53
+ const WEB_READ_ACTIONS = new Set(["search", "fetch", "browse", "crawl", "map", "extract", "answer"]);
54
+ /** Built-ins that read and change nothing. */
55
+ const READ_BUILTINS = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "TodoWrite", "LS", "NotebookRead"]);
56
+ /** The platform's own tool groups that only read. Consults are a toolless
57
+ * answer; a delegation's own calls come through on their own. */
58
+ const READ_SERVERS = new Set(["foldrun_search", "foldrun_history", "foldrun_desks", "foldrun_agents"]);
59
+ const DELEGATE = new Set(["Agent", "Task"]);
60
+ /** The browse actions a call asked for, nested `if` branches included. */
61
+ export function browseActionNames(actions) {
62
+ let steps = actions;
63
+ if (typeof steps === "string") {
64
+ try {
65
+ steps = JSON.parse(steps);
66
+ }
67
+ catch {
68
+ return ["(unreadable actions)"];
69
+ }
70
+ }
71
+ const out = [];
72
+ const walk = (v, depth) => {
73
+ if (depth > 8 || v === null || typeof v !== "object")
74
+ return;
75
+ if (Array.isArray(v)) {
76
+ for (const x of v)
77
+ walk(x, depth + 1);
78
+ return;
79
+ }
80
+ for (const [k, x] of Object.entries(v)) {
81
+ out.push(k);
82
+ walk(x, depth + 1);
83
+ }
84
+ };
85
+ walk(steps, 0);
86
+ return out;
87
+ }
88
+ /** What a browse call's arguments would write, or null when it only reads. */
89
+ export function browseWrites(input) {
90
+ const names = browseActionNames(input.actions);
91
+ if (names.includes("(unreadable actions)"))
92
+ return "browse actions it could not read";
93
+ const writes = [...new Set(names.filter((n) => BROWSE_WRITE_ACTIONS.includes(n)))];
94
+ if (writes.length)
95
+ return `browse ${writes.join("/")}`;
96
+ const arg = BROWSE_WRITE_ARGS.find((a) => input[a] !== undefined && input[a] !== null && input[a] !== "" && input[a] !== false);
97
+ if (arg)
98
+ return `browse ${arg}=`;
99
+ return null;
100
+ }
101
+ /**
102
+ * Whether one tool call can have changed anything outside the model's head,
103
+ * and if so, a few words saying what. Pure.
104
+ */
105
+ export function classifyCall(tool, input, ctx = {}) {
106
+ const args = input ?? {};
107
+ const write = (what) => ({ write: true, what });
108
+ if (READ_BUILTINS.has(tool) || DELEGATE.has(tool))
109
+ return { write: false };
110
+ if (tool === "Write" || tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") {
111
+ const raw = String(args.file_path ?? args.notebook_path ?? args.path ?? "");
112
+ const file = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : raw.split("/").slice(-3).join("/");
113
+ return write(file ? `${file} (${tool})` : `a file (${tool})`);
114
+ }
115
+ if (tool === "Bash")
116
+ return write("a shell command");
117
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
118
+ if (!mcp)
119
+ return write(tool); // unknown: a write until shown otherwise
120
+ const [, server, name] = mcp;
121
+ if (READ_SERVERS.has(server))
122
+ return { write: false };
123
+ if (server === "foldrun_ask")
124
+ return write("a question to a person");
125
+ if (server === "foldrun_scripts") {
126
+ if (name !== "web")
127
+ return write(`script ${name}`);
128
+ const action = String(args.action ?? "").trim().toLowerCase();
129
+ if (!WEB_READ_ACTIONS.has(action))
130
+ return write(action === "monitor" ? "web monitor state" : `web ${action || "call"}`);
131
+ const paid = ctx.paidWeb?.[action];
132
+ if (paid)
133
+ return write(`a paid ${paid} ${action}`);
134
+ if (action === "browse") {
135
+ const w = browseWrites(args);
136
+ if (w)
137
+ return write(w);
138
+ }
139
+ return { write: false };
140
+ }
141
+ if (server === "foldrun_apis") {
142
+ const method = String(name.startsWith("call_") ? (args.method ?? "GET") : (ctx.methods?.[tool] ?? "")).toUpperCase();
143
+ const api = name.replace(/^call_/, "");
144
+ if (method === "GET" || method === "HEAD")
145
+ return { write: false };
146
+ return write(`${api} ${method || "call"}`);
147
+ }
148
+ // Any other MCP server: its tools could do anything.
149
+ return write(`${server} ${name}`);
150
+ }
151
+ /** Paid web actions, from a step's env (FOLDRUN_WEB_<ACTION>_VIA=provider). */
152
+ export function paidWebActions(env) {
153
+ const out = {};
154
+ for (const [k, v] of Object.entries(env)) {
155
+ const m = /^FOLDRUN_WEB_([A-Z]+)_VIA$/.exec(k);
156
+ if (m && v)
157
+ out[m[1].toLowerCase()] = v;
158
+ }
159
+ if (env.FOLDRUN_BROWSER_VENDOR)
160
+ out.browse = env.FOLDRUN_BROWSER_VENDOR;
161
+ return out;
162
+ }
163
+ /** ", at 10:15:15Z" from a pod log line's `at`; empty when it has none. */
164
+ export function podAt(at) {
165
+ if (typeof at !== "string")
166
+ return "";
167
+ const t = Date.parse(at);
168
+ return Number.isNaN(t) ? "" : `, at ${new Date(t).toISOString().slice(11, 19)}Z`;
169
+ }
170
+ /** Lines of the pod log from byte `offset` on, and where the next read
171
+ * starts. A torn last line waits for the next read. */
172
+ export function readPodEvents(text, offset) {
173
+ const chunk = text.slice(offset);
174
+ const end = chunk.lastIndexOf("\n");
175
+ if (end < 0)
176
+ return { events: [], next: offset };
177
+ const events = [];
178
+ for (const line of chunk.slice(0, end).split("\n")) {
179
+ try {
180
+ const e = JSON.parse(line);
181
+ if (e && (e.kind === "reconnect" || e.kind === "lost" || e.kind === "needs-full"))
182
+ events.push(e);
183
+ }
184
+ catch {
185
+ // not ours
186
+ }
187
+ }
188
+ return { events, next: offset + end + 1 };
189
+ }
190
+ /** "a.md (Write), crm POST and 2 more". */
191
+ export function describeWrites(writes) {
192
+ const uniq = [...new Set(writes)];
193
+ if (uniq.length > 3)
194
+ return `${uniq.slice(0, 3).join(", ")} and ${uniq.length - 3} more`;
195
+ if (uniq.length <= 1)
196
+ return uniq[0] ?? "something";
197
+ return `${uniq.slice(0, -1).join(", ")} and ${uniq.at(-1)}`;
198
+ }
199
+ /** The decision, once the pod is gone: re-run on full, or fail and say why. */
200
+ export function podLossDecision(pod) {
201
+ const writes = pod.writes ?? [];
202
+ const cause = pod.lost?.cause === "needs-full" ? `a call needed a browser in the step (${pod.lost.detail})` : `browser pod lost (${pod.lost?.detail ?? "connection closed"})`;
203
+ if (!writes.length)
204
+ return { rerun: true, note: `${RERAN_ON_FULL} — ${cause}; the step had only read, so it ran again from the start on the full image` };
205
+ const head = pod.lost?.cause === "needs-full"
206
+ ? `a call needed a browser in the step after the step had written ${describeWrites(writes)}`
207
+ : `browser pod died after the step had written ${describeWrites(writes)}`;
208
+ return { rerun: false, message: `${head} — not re-run, since doing it again could repeat that; retry:, on-fail: or a person decides (${pod.lost?.detail ?? "connection closed"})` };
209
+ }
210
+ /**
211
+ * Why a browsing step must have a browser in its own sandbox, from what the
212
+ * step start can see (the agent's `web.browse` block, as env), or null when
213
+ * the account's pod can serve every call it could make by default. A call
214
+ * that picks an in-step engine itself is caught at the call (needs-full).
215
+ */
216
+ export function slimBrowseBlocker(env) {
217
+ if (env.FOLDRUN_BROWSER_VENDOR)
218
+ return `web.browse names ${env.FOLDRUN_BROWSER_VENDOR}`;
219
+ if (env.FOLDRUN_BROWSER_LIVE === "1")
220
+ return "web.browse live: sessions run in the step";
221
+ const engine = (env.FOLDRUN_BROWSER_ENGINE ?? "").trim().toLowerCase();
222
+ if (engine === "lightpanda" || engine === "obscura")
223
+ return `engine ${engine} runs in the step`;
224
+ if (env.FOLDRUN_BROWSER_EXTENSIONS)
225
+ return "extensions load into a browser in the step";
226
+ return null;
227
+ }
228
+ /** The outcome as it crosses back from the sandbox, checked field by field
229
+ * (the done line is the pod's word); null when absent or not this shape. */
230
+ export function parseBrowserPod(v) {
231
+ if (!v || typeof v !== "object")
232
+ return null;
233
+ const o = v;
234
+ const n = (x) => (typeof x === "number" && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
235
+ const out = { reconnects: n(o.reconnects), reconnected: n(o.reconnected) };
236
+ if (n(o.closedAgain))
237
+ out.closedAgain = n(o.closedAgain);
238
+ const lost = o.lost;
239
+ if (lost && typeof lost === "object" && (lost.cause === "lost" || lost.cause === "needs-full")) {
240
+ out.lost = { cause: lost.cause, detail: String(lost.detail ?? "").slice(0, 300) };
241
+ out.writes = Array.isArray(o.writes) ? o.writes.slice(0, 50).map((w) => String(w).slice(0, 120)) : [];
242
+ }
243
+ return out;
244
+ }
245
+ /** One line for a person: "2 reconnects (2 got through)", "browser pod
246
+ * lost; re-ran on full", or the failure. What the run page, `foldrun
247
+ * report` and an attempt row say. A pod that was lost in the end is never
248
+ * said to have let a reconnect through — "1 reconnect (1 got through); pod
249
+ * lost" read live as a contradiction — and a reconnect that reached the pod
250
+ * only for the call to drop again says that. */
251
+ export function browserPodLine(p) {
252
+ const count = `${p.reconnects} reconnect${p.reconnects === 1 ? "" : "s"}`;
253
+ const tries = !p.reconnects
254
+ ? "no reconnects"
255
+ : !p.lost
256
+ ? `${count} (${p.reconnected} got through)`
257
+ : p.closedAgain
258
+ ? `${count}, ${p.reconnects === 1 ? "which" : "the last"} reached the pod but it closed again`
259
+ : count;
260
+ if (p.failure)
261
+ return `${tries}; ${p.failure}`;
262
+ if (p.fallback)
263
+ return `${tries}; ${p.fallback}${p.lost ? ` (${p.lost.detail})` : ""}`;
264
+ if (p.lost)
265
+ return `${tries}; ${p.lost.cause === "needs-full" ? "needed a browser in the step" : "pod lost"}: ${p.lost.detail}`;
266
+ return tries;
267
+ }
268
+ /** A step's tries in one line when the browser pod was lost under one of
269
+ * them — "slim · lost · $0.0040 → full · completed · $0.0060" — so the run
270
+ * page and `foldrun report` show the slim go and the full re-run apart.
271
+ * `#n` leads each try when the step had more than one attempt. Null when
272
+ * no try was lost. */
273
+ export function podTriesLine(tries) {
274
+ if (!tries?.some((t) => t.status === "lost"))
275
+ return null;
276
+ const many = new Set(tries.map((t) => t.n)).size > 1;
277
+ return tries
278
+ .map((t) => [
279
+ `${many ? `#${t.n} ` : ""}${t.image ?? "?"}`,
280
+ t.status,
281
+ typeof t.costUsd === "number" ? `$${t.costUsd.toFixed(4)}` : null,
282
+ ].filter(Boolean).join(" · "))
283
+ .join(" → ");
284
+ }
@@ -0,0 +1,32 @@
1
+ export type BudgetPeriod = "run" | "day" | "week" | "month";
2
+ export interface Budget {
3
+ usd: number;
4
+ period: BudgetPeriod;
5
+ }
6
+ /**
7
+ * The budget a frontmatter value declares, or null for "no cap".
8
+ *
9
+ * Lenient on purpose: a value nobody can read is treated as no cap rather
10
+ * than as a cap of zero, because refusing every run over a typo is the worse
11
+ * failure. `budgetProblem` is the strict reading, for lint and the editor.
12
+ */
13
+ export declare function parseBudget(raw: unknown, defaultPeriod: BudgetPeriod): Budget | null;
14
+ /**
15
+ * What is wrong with a `budget:` value, in a sentence, or null when it is
16
+ * fine. `allowed` names the periods this file may use — an agent's or a
17
+ * flow's budget is per run and nothing else; a workspace's or the account's
18
+ * is over time and never per run.
19
+ */
20
+ export declare function budgetProblem(raw: unknown, allowed: BudgetPeriod[]): string | null;
21
+ /** "$60/day", "$500/month", "$6/run". */
22
+ export declare function formatBudget(b: Budget): string;
23
+ /** "today", "this week", "this month" — the window a spend is measured in. */
24
+ export declare function windowName(period: BudgetPeriod): string;
25
+ /** What to wait for when the cap is reached. */
26
+ export declare function windowTurnsHint(period: BudgetPeriod): string;
27
+ /**
28
+ * When the current window began, as a UTC instant — the moment spend is
29
+ * summed from. Today's midnight, Monday's midnight, or the first of the
30
+ * month, all in the given calendar. A per-run budget has no window.
31
+ */
32
+ export declare function windowStartMs(period: BudgetPeriod, timeZone: string, now?: Date): number | null;