@foldrun/core 0.4.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 (155) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/account.d.ts +45 -0
  4. package/dist/src/account.js +130 -0
  5. package/dist/src/api-tools.d.ts +8 -0
  6. package/dist/src/api-tools.js +22 -4
  7. package/dist/src/browser-pod.d.ts +129 -0
  8. package/dist/src/browser-pod.js +284 -0
  9. package/dist/src/catalog.js +2 -1
  10. package/dist/src/clock.d.ts +49 -0
  11. package/dist/src/clock.js +121 -0
  12. package/dist/src/completions.js +17 -8
  13. package/dist/src/confine.d.ts +22 -0
  14. package/dist/src/confine.js +218 -3
  15. package/dist/src/deploy.d.ts +33 -0
  16. package/dist/src/deploy.js +145 -6
  17. package/dist/src/egress.d.ts +7 -0
  18. package/dist/src/egress.js +6 -0
  19. package/dist/src/eol.d.ts +2 -0
  20. package/dist/src/eol.js +7 -0
  21. package/dist/src/evals.d.ts +5 -0
  22. package/dist/src/evals.js +6 -2
  23. package/dist/src/flow-lint.d.ts +8 -0
  24. package/dist/src/flow-lint.js +223 -1
  25. package/dist/src/flow-patterns.d.ts +141 -0
  26. package/dist/src/flow-patterns.js +568 -0
  27. package/dist/src/gitrepo.d.ts +18 -0
  28. package/dist/src/gitrepo.js +49 -0
  29. package/dist/src/kinds.js +1 -1
  30. package/dist/src/language.d.ts +17 -0
  31. package/dist/src/language.js +55 -0
  32. package/dist/src/layout.d.ts +69 -0
  33. package/dist/src/layout.js +200 -0
  34. package/dist/src/library.d.ts +9 -0
  35. package/dist/src/library.js +30 -3
  36. package/dist/src/limits.d.ts +84 -0
  37. package/dist/src/limits.js +287 -0
  38. package/dist/src/locale.d.ts +47 -0
  39. package/dist/src/locale.js +177 -0
  40. package/dist/src/mail.d.ts +88 -0
  41. package/dist/src/mail.js +245 -0
  42. package/dist/src/model-probe.d.ts +3 -0
  43. package/dist/src/model-probe.js +1 -0
  44. package/dist/src/notify.d.ts +53 -7
  45. package/dist/src/notify.js +194 -126
  46. package/dist/src/openapi.js +5 -3
  47. package/dist/src/operator.d.ts +201 -0
  48. package/dist/src/operator.js +251 -0
  49. package/dist/src/paths.d.ts +28 -0
  50. package/dist/src/paths.js +55 -0
  51. package/dist/src/platform.d.ts +43 -3
  52. package/dist/src/platform.js +9 -1
  53. package/dist/src/provider-health.js +3 -2
  54. package/dist/src/providers.d.ts +481 -0
  55. package/dist/src/providers.js +1105 -6
  56. package/dist/src/refusal.d.ts +83 -0
  57. package/dist/src/refusal.js +281 -0
  58. package/dist/src/run-container.d.ts +149 -13
  59. package/dist/src/run-container.js +403 -42
  60. package/dist/src/runner.d.ts +142 -9
  61. package/dist/src/runner.js +1027 -122
  62. package/dist/src/runtime.d.ts +52 -0
  63. package/dist/src/runtime.js +493 -58
  64. package/dist/src/script-tools.d.ts +3 -2
  65. package/dist/src/script-tools.js +35 -3
  66. package/dist/src/secret-files.d.ts +6 -5
  67. package/dist/src/secret-files.js +24 -11
  68. package/dist/src/secret-health.d.ts +30 -6
  69. package/dist/src/secret-health.js +42 -20
  70. package/dist/src/secrets.d.ts +19 -0
  71. package/dist/src/secrets.js +155 -86
  72. package/dist/src/starter.d.ts +9 -0
  73. package/dist/src/starter.js +92 -11
  74. package/dist/src/step-exec.d.ts +61 -2
  75. package/dist/src/step-exec.js +414 -36
  76. package/dist/src/step-removal.d.ts +15 -0
  77. package/dist/src/step-removal.js +49 -0
  78. package/dist/src/storage.d.ts +23 -4
  79. package/dist/src/storage.js +125 -23
  80. package/dist/src/store.d.ts +218 -37
  81. package/dist/src/store.js +347 -60
  82. package/dist/src/subagents.d.ts +75 -0
  83. package/dist/src/subagents.js +149 -0
  84. package/dist/src/tool-names.d.ts +13 -1
  85. package/dist/src/tool-names.js +38 -11
  86. package/dist/src/tool-programs.d.ts +26 -0
  87. package/dist/src/tool-programs.js +172 -0
  88. package/dist/src/tool-test.js +104 -12
  89. package/dist/src/translator.d.ts +8 -2
  90. package/dist/src/translator.js +69 -23
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/triggers.d.ts +3 -0
  93. package/dist/src/triggers.js +10 -2
  94. package/dist/src/untrusted-fetch.d.ts +11 -0
  95. package/dist/src/untrusted-fetch.js +77 -0
  96. package/dist/src/web-actions.d.ts +91 -0
  97. package/dist/src/web-actions.js +199 -0
  98. package/dist/src/webhook.d.ts +4 -0
  99. package/dist/src/webhook.js +39 -0
  100. package/index.ts +3 -0
  101. package/package.json +3 -3
  102. package/src/account.ts +149 -0
  103. package/src/api-tools.ts +32 -4
  104. package/src/browser-pod.ts +313 -0
  105. package/src/catalog.ts +2 -1
  106. package/src/clock.ts +149 -0
  107. package/src/completions.ts +17 -8
  108. package/src/confine.ts +218 -3
  109. package/src/deploy.ts +137 -6
  110. package/src/egress.ts +13 -1
  111. package/src/eol.ts +7 -0
  112. package/src/evals.ts +11 -2
  113. package/src/flow-lint.ts +253 -1
  114. package/src/flow-patterns.ts +597 -0
  115. package/src/gitrepo.ts +48 -0
  116. package/src/kinds.ts +1 -1
  117. package/src/language.ts +62 -0
  118. package/src/layout.ts +228 -0
  119. package/src/library.ts +37 -3
  120. package/src/limits.ts +290 -0
  121. package/src/locale.ts +217 -0
  122. package/src/mail.ts +316 -0
  123. package/src/model-probe.ts +4 -0
  124. package/src/notify.ts +189 -103
  125. package/src/openapi.ts +5 -3
  126. package/src/operator.ts +300 -0
  127. package/src/paths.ts +54 -0
  128. package/src/platform.ts +52 -3
  129. package/src/provider-health.ts +3 -2
  130. package/src/providers.ts +1368 -6
  131. package/src/refusal.ts +320 -0
  132. package/src/run-container.ts +439 -43
  133. package/src/runner.ts +1050 -122
  134. package/src/runtime.ts +487 -55
  135. package/src/script-tools.ts +38 -5
  136. package/src/secret-files.ts +22 -10
  137. package/src/secret-health.ts +64 -19
  138. package/src/secrets.ts +149 -87
  139. package/src/starter.ts +93 -11
  140. package/src/step-exec.ts +429 -37
  141. package/src/step-removal.ts +59 -0
  142. package/src/storage.ts +141 -22
  143. package/src/store.ts +500 -68
  144. package/src/subagents.ts +189 -0
  145. package/src/tool-names.ts +40 -11
  146. package/src/tool-programs.ts +179 -0
  147. package/src/tool-test.ts +110 -12
  148. package/src/translator.ts +72 -22
  149. package/src/trigger-log.ts +1 -0
  150. package/src/triggers.ts +10 -2
  151. package/src/untrusted-fetch.ts +88 -0
  152. package/src/web-actions.ts +237 -0
  153. package/src/webhook.ts +41 -0
  154. package/templates/hello/agents/curator/agent.md +1 -1
  155. package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/translator.ts CHANGED
@@ -31,6 +31,7 @@ import crypto from "node:crypto";
31
31
  import { PROTECTED_PARAMS } from "./providers.ts";
32
32
  import { viaEgress } from "./egress.ts";
33
33
  import { trimSlashes } from "./paths.ts";
34
+ import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.ts";
34
35
 
35
36
  // ------------------------------------------------------------------ shapes
36
37
 
@@ -60,6 +61,10 @@ export interface TranslatorSpec {
60
61
  * upstream request goes through it, and `upstreamKey` is a placeholder
61
62
  * the proxy fills. */
62
63
  egress?: string;
64
+ /** The step's own zone, from the clock cascade — what a reset time in a
65
+ * refusal is told in. UTC when nothing said otherwise. */
66
+ timezone?: string;
67
+
63
68
  }
64
69
 
65
70
  export interface RunningTranslator {
@@ -419,16 +424,38 @@ export function toResponses(
419
424
 
420
425
  // ------------------------------------------------------- response mapping
421
426
 
422
- function stopReason(finish: unknown): string {
423
- switch (finish) {
424
- case "tool_calls":
425
- case "function_call":
426
- return "tool_use";
427
- case "length":
428
- return "max_tokens";
429
- default:
430
- return "end_turn";
431
- }
427
+ type Stop = { reason: string; sequence: string | null };
428
+
429
+ /**
430
+ * Why a Chat Completion stopped, in Anthropic's words. `content_filter`
431
+ * (OpenAI, Azure, Gemini's safety block) is a refusal; Mistral's
432
+ * `model_length` is the context window filling, not max_tokens. A stop
433
+ * sequence is named only where the provider says which one matched — vLLM
434
+ * as `stop_reason`, SGLang as `matched_stop` — and only when it is one the
435
+ * request asked for; plain OpenAI says `stop` either way, which reads as
436
+ * end_turn. pause_turn has no counterpart: server-side tools are dropped.
437
+ */
438
+ function chatStop(choice: Json, sawTool: boolean, stops: readonly string[]): Stop {
439
+ const finish = choice.finish_reason;
440
+ if (finish === "content_filter") return { reason: "refusal", sequence: null };
441
+ if (finish === "length") return { reason: "max_tokens", sequence: null };
442
+ if (finish === "model_length") return { reason: "model_context_window_exceeded", sequence: null };
443
+ if (sawTool || finish === "tool_calls" || finish === "function_call") return { reason: "tool_use", sequence: null };
444
+ const matched = typeof choice.stop_reason === "string" ? choice.stop_reason : choice.matched_stop;
445
+ if (typeof matched === "string" && stops.includes(matched)) return { reason: "stop_sequence", sequence: matched };
446
+ return { reason: "end_turn", sequence: null };
447
+ }
448
+
449
+ /** Why a Response stopped: a refusal part or a content filter is a refusal. */
450
+ function responsesStop(incomplete: unknown, sawTool: boolean, sawRefusal: boolean): string {
451
+ if (sawRefusal || incomplete === "content_filter") return "refusal";
452
+ if (incomplete === "max_output_tokens") return "max_tokens";
453
+ if (sawTool) return "tool_use";
454
+ return "end_turn";
455
+ }
456
+
457
+ function stopList(stops: unknown): string[] {
458
+ return Array.isArray(stops) ? stops.filter((s): s is string => typeof s === "string") : [];
432
459
  }
433
460
 
434
461
  function parseArgs(raw: unknown): unknown {
@@ -441,7 +468,7 @@ function parseArgs(raw: unknown): unknown {
441
468
  }
442
469
 
443
470
  /** A finished Chat Completion as an Anthropic message. Pure. */
444
- export function fromChatCompletion(res: Json, requestedModel: string): Json {
471
+ export function fromChatCompletion(res: Json, requestedModel: string, stopSequences?: unknown): Json {
445
472
  const choice = ((res.choices as Json[] | undefined) ?? [])[0] ?? {};
446
473
  const msg = (choice.message as Json | undefined) ?? {};
447
474
  const content: Json[] = [];
@@ -457,14 +484,15 @@ export function fromChatCompletion(res: Json, requestedModel: string): Json {
457
484
  });
458
485
  }
459
486
  const usage = (res.usage as Json | undefined) ?? {};
487
+ const stop = chatStop(choice, content.some((c) => c.type === "tool_use"), stopList(stopSequences));
460
488
  return {
461
489
  id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
462
490
  type: "message",
463
491
  role: "assistant",
464
492
  model: String(res.model ?? requestedModel),
465
493
  content,
466
- stop_reason: stopReason(choice.finish_reason),
467
- stop_sequence: null,
494
+ stop_reason: stop.reason,
495
+ stop_sequence: stop.sequence,
468
496
  usage: {
469
497
  input_tokens: Number(usage.prompt_tokens ?? 0),
470
498
  output_tokens: Number(usage.completion_tokens ?? 0),
@@ -485,12 +513,15 @@ export class StreamTranslator {
485
513
  private textIndex: number | null = null;
486
514
  /** Chat-Completions tool index → Anthropic block index. */
487
515
  private tools = new Map<number, { block: number; argsSeen: boolean }>();
488
- private finish: unknown = null;
516
+ /** The last choice's stop fields: finish_reason and any matched stop. */
517
+ private finish: Json = {};
489
518
  private usage: { input: number; output: number } | null = null;
490
519
  private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
491
520
  private readonly model: string;
492
- constructor(model: string) {
521
+ private readonly stops: string[];
522
+ constructor(model: string, stopSequences?: unknown) {
493
523
  this.model = model;
524
+ this.stops = stopList(stopSequences);
494
525
  }
495
526
 
496
527
  private event(name: string, data: Json): string {
@@ -534,7 +565,7 @@ export class StreamTranslator {
534
565
  }
535
566
  const choice = ((chunk.choices as Json[] | undefined) ?? [])[0];
536
567
  if (!choice) return out;
537
- if (choice.finish_reason) this.finish = choice.finish_reason;
568
+ if (choice.finish_reason) this.finish = { finish_reason: choice.finish_reason, stop_reason: choice.stop_reason, matched_stop: choice.matched_stop };
538
569
  const delta = (choice.delta as Json | undefined) ?? {};
539
570
 
540
571
  const text = typeof delta.content === "string" ? delta.content : "";
@@ -614,11 +645,11 @@ export class StreamTranslator {
614
645
  }
615
646
  out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
616
647
  }
617
- const reason = this.tools.size && this.finish !== "length" ? "tool_use" : stopReason(this.finish);
648
+ const stop = chatStop(this.finish, this.tools.size > 0, this.stops);
618
649
  out.push(
619
650
  this.event("message_delta", {
620
651
  type: "message_delta",
621
- delta: { stop_reason: reason, stop_sequence: null },
652
+ delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
622
653
  usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
623
654
  }),
624
655
  );
@@ -648,7 +679,10 @@ export function fromResponses(res: Json, requestedModel: string): Json {
648
679
  }
649
680
  const usage = (res.usage as Json | undefined) ?? {};
650
681
  const incomplete = (res.incomplete_details as Json | undefined)?.reason;
651
- const stop = content.some((c) => c.type === "tool_use") ? "tool_use" : incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
682
+ const refused = (Array.isArray(res.output) ? (res.output as Json[]) : []).some(
683
+ (item) => item.type === "message" && Array.isArray(item.content) && (item.content as Json[]).some((c) => c.type === "refusal"),
684
+ );
685
+ const stop = responsesStop(incomplete, content.some((c) => c.type === "tool_use"), refused);
652
686
  return {
653
687
  id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
654
688
  type: "message",
@@ -679,6 +713,7 @@ export class ResponsesStreamTranslator {
679
713
  private usage: { input: number; output: number } | null = null;
680
714
  private incomplete: string | null = null;
681
715
  private sawTool = false;
716
+ private sawRefusal = false;
682
717
  private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
683
718
  private readonly model: string;
684
719
  constructor(model: string) {
@@ -743,6 +778,7 @@ export class ResponsesStreamTranslator {
743
778
  if (type === "response.output_text.delta" || type === "response.refusal.delta") {
744
779
  const entry = this.blocks.get(Number(ev.output_index));
745
780
  const text = String(ev.delta ?? "");
781
+ if (type === "response.refusal.delta") this.sawRefusal = true;
746
782
  if (entry && entry.kind === "text" && text) {
747
783
  out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
748
784
  }
@@ -793,7 +829,7 @@ export class ResponsesStreamTranslator {
793
829
  }
794
830
  out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
795
831
  }
796
- const reason = this.sawTool && this.incomplete !== "max_output_tokens" ? "tool_use" : this.incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
832
+ const reason = responsesStop(this.incomplete, this.sawTool, this.sawRefusal);
797
833
  out.push(
798
834
  this.event("message_delta", {
799
835
  type: "message_delta",
@@ -913,6 +949,20 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
913
949
  const text = await upstream.text();
914
950
  const message = upstreamMessage(text, upstream.status);
915
951
  log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
952
+ // The refusal in words, from the headers the response already
953
+ // carried. Only on a refusal, and only the allow-listed headers:
954
+ // a 400 about a malformed body is the work's problem, not the
955
+ // supply's, and explaining it as one would mislead.
956
+ if (isRefusalStatus(upstream.status)) {
957
+ log.push(
958
+ refusalLine({
959
+ status: upstream.status,
960
+ headers: captureRefusalHeaders(upstream.headers),
961
+ timezone: spec.timezone,
962
+ provider: label,
963
+ }),
964
+ );
965
+ }
916
966
  // The status crosses unchanged, so a 401 or 429 from the provider
917
967
  // reads as one to the SDK — and to the fallback decision upstream.
918
968
  return send(upstream.status, errorBody(upstream.status, message));
@@ -928,11 +978,11 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
928
978
  return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
929
979
  }
930
980
  log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
931
- return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
981
+ return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model, body.stop_sequences)));
932
982
  }
933
983
 
934
984
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
935
- const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
985
+ const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model, body.stop_sequences);
936
986
  const reader = upstream.body?.getReader();
937
987
  if (!reader) {
938
988
  for (const e of machine.finishStream()) res.write(e);
@@ -38,6 +38,7 @@ export type TriggerOutcome =
38
38
  | "throttled"
39
39
  | "debounced"
40
40
  | "quarantined"
41
+ | "paused"
41
42
  | "overlap-skipped"
42
43
  | "missed"
43
44
  | "failed";
package/src/triggers.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import crypto from "node:crypto";
16
16
  import { platform } from "./platform.ts";
17
- import { listFlows, readRun, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
17
+ import { listFlows, readRun, runVerdict, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
18
18
  import { getSecret } from "./secrets.ts";
19
19
 
20
20
  /** The last step's result — the same reading runner.ts makes, kept local so
@@ -36,6 +36,13 @@ export function withTask(steps: FlowStep[], tag: string, body: string): FlowStep
36
36
 
37
37
  // ------------------------------------------------------------ trigger: flow
38
38
 
39
+ /** Which `on:` a settled run answers to: failed, blocked (completed, but
40
+ * its work refused itself), or completed. */
41
+ export function endingOf(run: RunRecord): "completed" | "failed" | "blocked" {
42
+ if (run.status === "failed") return "failed";
43
+ return runVerdict(run) === "BLOCKED" ? "blocked" : "completed";
44
+ }
45
+
39
46
  /** The flows a finished run should start, by this workspace's own files. */
40
47
  export function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[] {
41
48
  if (finished.status !== "completed" && finished.status !== "failed") return [];
@@ -48,7 +55,7 @@ export function chainedFlows(tenant: string, workspace: string, finished: RunRec
48
55
  // A flow chained on itself is a loop with no bound — refused here and
49
56
  // by foldrun check, never started.
50
57
  f.name !== finished.flow &&
51
- (f.on === "any" || f.on === finished.status),
58
+ (f.on === "any" || f.on === endingOf(finished)),
52
59
  );
53
60
  }
54
61
 
@@ -66,6 +73,7 @@ export async function fireChainedFlows(tenant: string, workspace: string, finish
66
73
  const result = (runResult(finished) ?? "").slice(0, 20_000);
67
74
  const body =
68
75
  `run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
76
+ (runVerdict(finished) ? `verdict: ${runVerdict(finished)}\n` : "") +
69
77
  (finished.summary ? `summary: ${finished.summary}\n` : "") +
70
78
  (result ? `\n${result}` : "");
71
79
  for (const flow of flows) {
@@ -0,0 +1,88 @@
1
+ // fetch() for a URL a tenant chose, redirects included.
2
+ //
3
+ // platform.fetchUntrusted is the seam: on a platform it refuses private and
4
+ // cluster addresses (by name, by IP literal, and at connect time through a
5
+ // guarded DNS lookup). But it was handed fetch's default `redirect:
6
+ // "follow"`, and a redirect is followed inside fetch, past the seam: a
7
+ // public host answering `302 Location: http://169.254.169.254/…` sent the
8
+ // platform to the metadata endpoint, because an IP literal never reaches the
9
+ // DNS lookup the connect-time guard lives in. And the OAuth token POSTs
10
+ // carried a client secret to wherever a 307 pointed.
11
+ //
12
+ // So redirects are followed here, one hop at a time, each hop through the
13
+ // seam again — every address a request ends up at is checked the same way
14
+ // the first one was. A hop to another origin keeps only the headers that
15
+ // cannot carry a credential, and a caller whose request IS a credential
16
+ // (a token exchange, a provider key in a header) follows none at all.
17
+
18
+ import { platform } from "./platform.ts";
19
+
20
+ /** How many redirects one request may follow. fetch's own limit is 20;
21
+ * nothing a tenant points the platform at needs more than a couple. */
22
+ export const MAX_REDIRECTS = 5;
23
+
24
+ const REDIRECT = new Set([301, 302, 303, 307, 308]);
25
+
26
+ /** Headers that may cross to another origin: none of them can carry a
27
+ * credential. Everything else — Authorization, a cookie, an x-api-key, a
28
+ * webhook's signature — stays with the origin it was meant for. */
29
+ const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
30
+
31
+ export class RedirectRefused extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "RedirectRefused";
35
+ }
36
+ }
37
+
38
+ export async function fetchUntrusted(
39
+ url: string,
40
+ init: RequestInit = {},
41
+ opts: {
42
+ /** The request carries a credential (in its body or its headers):
43
+ * follow a redirect only within the origin it was sent to. */
44
+ sameOrigin?: boolean;
45
+ } = {},
46
+ ): Promise<Response> {
47
+ let current = url;
48
+ let req: RequestInit = { ...init };
49
+ for (let hop = 0; ; hop++) {
50
+ const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
51
+ const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
52
+ if (!location) return res;
53
+ // The body of a redirect is never read; free the socket.
54
+ await res.body?.cancel().catch(() => {});
55
+ if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
56
+ let next: URL;
57
+ try {
58
+ next = new URL(location, current);
59
+ } catch {
60
+ throw new RedirectRefused(`${current} redirected to "${location}", which is not a URL`);
61
+ }
62
+ if (next.protocol !== "https:" && next.protocol !== "http:") {
63
+ throw new RedirectRefused(`${current} redirected to a ${next.protocol} URL — not followed`);
64
+ }
65
+ const from = new URL(current);
66
+ const crossOrigin = next.origin !== from.origin;
67
+ if (crossOrigin && opts.sameOrigin) {
68
+ throw new RedirectRefused(
69
+ `${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`,
70
+ );
71
+ }
72
+ // fetch's own rewrite: a 303, and a 301/302 answering a POST, is
73
+ // followed as a GET with no body; a 307/308 repeats the request as it was.
74
+ const method = (req.method ?? "GET").toUpperCase();
75
+ const headers = new Headers(req.headers);
76
+ if ((res.status === 303 && method !== "GET" && method !== "HEAD") || ((res.status === 301 || res.status === 302) && method === "POST")) {
77
+ req = { ...req, method: "GET", body: undefined };
78
+ headers.delete("content-type");
79
+ }
80
+ if (crossOrigin) {
81
+ const names: string[] = [];
82
+ headers.forEach((_, name) => names.push(name));
83
+ for (const name of names) if (!CROSS_ORIGIN_SAFE.has(name)) headers.delete(name);
84
+ }
85
+ req = { ...req, headers };
86
+ current = next.toString();
87
+ }
88
+ }
@@ -0,0 +1,237 @@
1
+ // The web, as actions. One question answered in one place: which provider
2
+ // can do which action, and what does foldrun do when none is named?
3
+ //
4
+ // An action is a call with a fixed input and a fixed output — search takes a
5
+ // query and returns URLs, fetch takes a URL and returns the page — whoever
6
+ // answers it. A provider is the list of actions it has an adapter for. foldrun
7
+ // is a provider like any other, and the one used when `web: {<action>: …}` is
8
+ // unset. A provider named for an action it cannot do is an error in `check`,
9
+ // never a quiet fall back to ours: a fallback would bill and behave
10
+ // differently from what the file says.
11
+ //
12
+ // Browse has a second level: inside a page, the steps and modes of
13
+ // the browse action. A browser reached over CDP is driven by our own code, so it
14
+ // takes every step and mode unless it is listed as lacking one; a vendor
15
+ // that runs the steps itself would list the few it has.
16
+ //
17
+ // The vendor facts (endpoint, key, docs page, the date the adapter was last
18
+ // matched to those docs) stay on SEARCH_APIS, FETCH_APIS and BROWSER_APIS in
19
+ // providers.ts; this file reads them rather than restating them.
20
+
21
+ import { SEARCH_APIS, FETCH_APIS, BROWSER_APIS, ACTION_APIS, WEB_ACTIONS, webConfig, type WebAction } from "./providers.ts";
22
+
23
+ export { WEB_ACTIONS, type WebAction };
24
+
25
+ /** What the browse action can return — its `mode`. Kept equal to the tool's own
26
+ * MODES by the platform's gallery test. */
27
+ export const BROWSE_MODES = [
28
+ "text", "markdown", "html", "links", "meta", "table", "network", "screenshot", "pdf", "capture",
29
+ "aria", "map", "crawl", "console", "vitals", "a11y", "diff", "feed", "webmcp", "react",
30
+ ] as const;
31
+
32
+ /** What the browse action can do in a page — its `actions` steps. Kept equal to
33
+ * the tool's own ACTIONS by the platform's gallery test. */
34
+ export const BROWSE_STEPS = [
35
+ "click", "dblclick", "rightclick", "hover", "check", "uncheck", "drag", "fill", "type", "press", "select",
36
+ "wait", "goto", "screenshot", "pdf", "scroll", "paste", "upload", "download", "extract", "frame", "tab",
37
+ "back", "forward", "reload", "focus", "clear", "highlight", "keyboard", "keydown", "keyup", "mouse", "wheel",
38
+ "tap", "swipe", "clipboard", "eval", "get", "expect", "if", "mock", "unmock", "offline", "cookie",
39
+ "localstorage", "sessionstorage", "viewport", "dialog", "solve", "pushstate", "insert", "webmcp", "login",
40
+ ] as const;
41
+
42
+ export interface ActionSupport {
43
+ /** live: callable today. planned: agreed and not built — naming it is an
44
+ * error that says so, rather than a tool that does nothing. */
45
+ status: "live" | "planned";
46
+ /** How: which tool serves it, and for foldrun's own, with what. */
47
+ how: string;
48
+ /** Browse only. cdp: our code drives the browser, so every step and mode
49
+ * works unless listed in `lacks`. api: the vendor runs the steps, and
50
+ * `steps`/`modes` list what it has. */
51
+ via?: "cdp" | "api";
52
+ steps?: readonly string[];
53
+ modes?: readonly string[];
54
+ lacks?: readonly string[];
55
+ /** The vendor page the adapter was built from, and the day it was last
56
+ * matched to it (YYYY-MM-DD). Absent on foldrun's own. */
57
+ docs?: string;
58
+ checked?: string;
59
+ }
60
+
61
+ export interface WebProvider {
62
+ name: string;
63
+ title: string;
64
+ actions: Partial<Record<WebAction, ActionSupport>>;
65
+ }
66
+
67
+ /** Ours: every action, and none of them with a second model in it. */
68
+ export const FOLDRUN: WebProvider = {
69
+ name: "foldrun",
70
+ title: "foldrun",
71
+ actions: {
72
+ search: { status: "live", how: "the account's own SearXNG" },
73
+ fetch: { status: "live", how: "one plain HTTP request in the sandbox" },
74
+ browse: { status: "live", how: "the account's browser pod", via: "cdp" },
75
+ crawl: { status: "live", how: "plain HTTP, same site, robots.txt honoured" },
76
+ map: { status: "live", how: "the site's sitemaps, else its links" },
77
+ extract: { status: "live", how: "selectors, or the page's own JSON-LD and OpenGraph — no model" },
78
+ answer: { status: "live", how: "search, read the top pages, rank the passages that answer — no model" },
79
+ monitor: { status: "live", how: "a page's lines or a query's results, against the last call, in state/" },
80
+ },
81
+ };
82
+
83
+ type Facts = { name: string; title: string; aliases?: string[]; docs?: string; checked?: string; lacks?: readonly string[] };
84
+
85
+ /** Every provider and what it can do, foldrun first. Built from the vendor
86
+ * lists so a vendor added there is here without a second edit. */
87
+ export function webProviders(): WebProvider[] {
88
+ const byName = new Map<string, WebProvider>();
89
+ const add = (f: Facts, action: WebAction, support: ActionSupport) => {
90
+ const p = byName.get(f.name) ?? { name: f.name, title: f.title, actions: {} };
91
+ p.actions[action] = { ...support, ...(f.docs ? { docs: f.docs } : {}), ...(f.checked ? { checked: f.checked } : {}) };
92
+ byName.set(f.name, p);
93
+ };
94
+ for (const a of SEARCH_APIS as readonly Facts[]) add(a, "search", { status: "live", how: a.title });
95
+ for (const a of FETCH_APIS as readonly Facts[]) add(a, "fetch", { status: "live", how: a.title });
96
+ for (const a of BROWSER_APIS as readonly Facts[]) {
97
+ add(a, "browse", { status: "live", how: a.title, via: "cdp", ...(a.lacks?.length ? { lacks: a.lacks } : {}) });
98
+ }
99
+ for (const [action, apis] of Object.entries(ACTION_APIS) as [WebAction, readonly Facts[]][]) {
100
+ for (const a of apis) add(a, action, { status: "live", how: a.title });
101
+ }
102
+ return [FOLDRUN, ...byName.values()];
103
+ }
104
+
105
+ /** The names a provider answers to, for lookups. */
106
+ function aliasesOf(name: string): string[] {
107
+ const all = [...SEARCH_APIS, ...FETCH_APIS, ...BROWSER_APIS, ...Object.values(ACTION_APIS).flat()] as readonly Facts[];
108
+ return all.filter((a) => a.name === name).flatMap((a) => a.aliases ?? []);
109
+ }
110
+
111
+ export function findWebProvider(name: string): WebProvider | undefined {
112
+ const key = name.trim().toLowerCase();
113
+ if (key === "" || key === "ours") return FOLDRUN;
114
+ return webProviders().find((p) => p.name === key || aliasesOf(p.name).includes(key));
115
+ }
116
+
117
+ /** The live providers for an action, foldrun first — for "these do" lines. */
118
+ export function providersFor(action: WebAction): string[] {
119
+ return webProviders().filter((p) => p.actions[action]?.status === "live").map((p) => p.name);
120
+ }
121
+
122
+ export type ActionChoice =
123
+ | { provider: WebProvider; support: ActionSupport; error?: undefined }
124
+ | { provider?: undefined; support?: undefined; error: string };
125
+
126
+ /**
127
+ * Who does `action`. Unset is foldrun. A named provider must have the action
128
+ * live; anything else is an error naming the providers that do — the rule is
129
+ * "fix the file", never "we used ours instead".
130
+ */
131
+ export function resolveWebAction(action: WebAction, name?: string | null): ActionChoice {
132
+ const field = `web.${action}`;
133
+ const p = name == null ? FOLDRUN : findWebProvider(name);
134
+ if (!p) {
135
+ return { error: `${field}: ${name} — no provider by that name. These ${action}: ${providersFor(action).join(", ")}.` };
136
+ }
137
+ const s = p.actions[action];
138
+ if (!s) {
139
+ const live = providersFor(action);
140
+ return {
141
+ error: `${field}: ${p.name} does not ${action} here.` +
142
+ (live.length ? ` These do: ${live.join(", ")}.` : " No provider has it wired yet.") +
143
+ (FOLDRUN.actions[action]?.status === "live" ? " Unset uses foldrun." : ""),
144
+ };
145
+ }
146
+ if (s.status === "planned") {
147
+ const others = providersFor(action).filter((n) => n !== p.name);
148
+ return {
149
+ error: `${field}: ${p.title}'s own ${action} is not built yet` +
150
+ (others.length ? ` — name a provider that has it: ${others.join(", ")}.` : " and no provider has it wired either."),
151
+ };
152
+ }
153
+ return { provider: p, support: s };
154
+ }
155
+
156
+ /** Can this browser run this step, or return this mode? */
157
+ export function browseSupports(support: ActionSupport, stepOrMode: string): boolean {
158
+ if (support.via === "cdp") return !(support.lacks ?? []).includes(stepOrMode);
159
+ return (support.steps ?? []).includes(stepOrMode) || (support.modes ?? []).includes(stepOrMode);
160
+ }
161
+
162
+ /** The browsers that can run a step or mode — for the error when one cannot. */
163
+ export function browsersFor(stepOrMode: string): string[] {
164
+ return webProviders()
165
+ .filter((p) => p.actions.browse?.status === "live" && browseSupports(p.actions.browse, stepOrMode))
166
+ .map((p) => p.name);
167
+ }
168
+
169
+ /** Vendor actions whose adapter has not been matched to the vendor's docs
170
+ * within `days` — each as "provider action (checked …)". Empty is healthy.
171
+ * The test that calls this is the rule "integrations follow the latest
172
+ * docs" in a form CI can fail on. */
173
+ export function staleIntegrations(today: string, days = 90): string[] {
174
+ const out: string[] = [];
175
+ const limit = Date.parse(today) - days * 86_400_000;
176
+ for (const p of webProviders()) {
177
+ if (p === FOLDRUN) continue;
178
+ for (const [action, s] of Object.entries(p.actions)) {
179
+ if (!s || s.status !== "live") continue;
180
+ if (!s.docs || !s.checked) out.push(`${p.name} ${action} (no docs page or check date recorded)`);
181
+ else if (Date.parse(s.checked) < limit) out.push(`${p.name} ${action} (checked ${s.checked})`);
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** The actions after search, fetch and browse. Those three are resolved by
188
+ * providers.ts, which also knows the model providers that search. */
189
+ export const NEWER_ACTIONS = ["crawl", "map", "extract", "answer", "monitor"] as const;
190
+ type NewerAction = (typeof NEWER_ACTIONS)[number];
191
+
192
+ export type ActionApiChoice =
193
+ | { provider: null; error?: undefined }
194
+ | { provider: string; secret: string; host: string; error?: undefined }
195
+ | { provider: null; error: string };
196
+
197
+ /**
198
+ * Who does one of the newer actions, and with which key: unset (or
199
+ * "foldrun") is ours and needs none; a name, or `{name, key: ${VAULT_NAME}}`
200
+ * for a key stored under another name, is that provider — if it has the
201
+ * action. The runner turns a provider into FOLDRUN_WEB_<ACTION>_VIA, the
202
+ * secret the step holds, and the one host that secret may reach.
203
+ */
204
+ export function resolveActionApi(action: NewerAction, raw: unknown): ActionApiChoice {
205
+ const field = `web.${action}`;
206
+ if (raw === undefined || raw === null || raw === "") return { provider: null };
207
+ let name: string;
208
+ let secret: string | undefined;
209
+ if (typeof raw === "string") name = raw;
210
+ else if (typeof raw === "object" && !Array.isArray(raw) && typeof (raw as { name?: unknown }).name === "string") {
211
+ name = (raw as { name: string }).name;
212
+ const key = (raw as { key?: unknown }).key;
213
+ if (key !== undefined) {
214
+ const m = typeof key === "string" ? /^\$\{([A-Z][A-Z0-9_]*)\}$/.exec(key.trim()) : null;
215
+ if (!m) return { provider: null, error: `${field}.key must be a \${NAME} reference to a secret in the vault, not the key itself.` };
216
+ secret = m[1];
217
+ }
218
+ } else {
219
+ return { provider: null, error: `${field}: takes a provider name, or a block with name: (and key: for your own vault name).` };
220
+ }
221
+ const choice = resolveWebAction(action, name);
222
+ if (choice.error !== undefined) return { provider: null, error: choice.error };
223
+ if (choice.provider.name === "foldrun") return { provider: null };
224
+ const api = ACTION_APIS[action].find((a) => a.name === choice.provider.name)!;
225
+ return { provider: api.name, secret: secret ?? api.secret, host: api.host };
226
+ }
227
+
228
+ /** Every newer action in the agent's web: block that names a provider unable
229
+ * to do it — the sentences resolveActionApi gives. Empty when all are unset
230
+ * or answerable. */
231
+ export function actionProblems(front: Record<string, unknown>): string[] {
232
+ const { raw } = webConfig(front);
233
+ return NEWER_ACTIONS.flatMap((a) => {
234
+ const e = resolveActionApi(a, raw[a]).error;
235
+ return e ? [e] : [];
236
+ });
237
+ }
package/src/webhook.ts CHANGED
@@ -143,8 +143,17 @@ export interface HookDelivery {
143
143
  runId?: string;
144
144
  bytes?: number;
145
145
  detail?: string;
146
+ /** A run of refusals, collapsed: how many, and when the first came.
147
+ * `t` is the latest. Absent means one. */
148
+ count?: number;
149
+ first?: string;
146
150
  }
147
151
 
152
+ /** What anyone can produce without the token or the signing secret. A run
153
+ * of these collapses into one line, so a stranger's flood costs the log a
154
+ * single entry and can never push an owner's real deliveries out of it. */
155
+ const UNAUTHENTICATED: HookDelivery["outcome"][] = ["invalid-token", "invalid-signature"];
156
+
148
157
  function deliveriesFile(tenant: string, workspace: string) {
149
158
  return path.join(workspaceDir(tenant, workspace), "hook-deliveries.jsonl");
150
159
  }
@@ -154,6 +163,7 @@ const KEEP_DELIVERIES = 500;
154
163
  export function recordDelivery(tenant: string, workspace: string, delivery: HookDelivery) {
155
164
  const file = deliveriesFile(tenant, workspace);
156
165
  if (!fs.existsSync(path.dirname(file))) return; // no workspace, nothing to log against
166
+ if (UNAUTHENTICATED.includes(delivery.outcome) && collapseInto(file, delivery)) return;
157
167
  fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
158
168
  // Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
159
169
  // and a log that grows forever is a disk-full incident with a delay.
@@ -169,6 +179,37 @@ export function recordDelivery(tenant: string, workspace: string, delivery: Hook
169
179
  }
170
180
  }
171
181
 
182
+ /** Fold a refusal into the log's last line when that line is a refusal
183
+ * too. The flow name is not part of the match: it comes from the URL, so
184
+ * a flood that varies it would otherwise never collapse. True when folded. */
185
+ function collapseInto(file: string, delivery: HookDelivery): boolean {
186
+ let text: string;
187
+ try {
188
+ text = fs.readFileSync(file, "utf8");
189
+ } catch {
190
+ return false;
191
+ }
192
+ const end = text.trimEnd();
193
+ const cut = end.lastIndexOf("\n") + 1;
194
+ let last: HookDelivery;
195
+ try {
196
+ last = JSON.parse(end.slice(cut)) as HookDelivery;
197
+ } catch {
198
+ return false;
199
+ }
200
+ if (!UNAUTHENTICATED.includes(last.outcome)) return false;
201
+ const merged: HookDelivery = {
202
+ ...delivery,
203
+ count: (last.count ?? 1) + 1,
204
+ first: last.first ?? last.t,
205
+ ...(last.detail === "more than one flow name" || last.flow !== delivery.flow ? { detail: "more than one flow name" } : {}),
206
+ };
207
+ const tmp = `${file}.tmp`;
208
+ fs.writeFileSync(tmp, end.slice(0, cut) + JSON.stringify(merged) + "\n");
209
+ fs.renameSync(tmp, file);
210
+ return true;
211
+ }
212
+
172
213
  export function readDeliveries(tenant: string, workspace: string, limit = 20): HookDelivery[] {
173
214
  const file = deliveriesFile(tenant, workspace);
174
215
  if (!fs.existsSync(file)) return [];
@@ -24,7 +24,7 @@ Do exactly this:
24
24
  - A fact a later run or a knowledge file contradicts: correct it, and say
25
25
  in the file what changed and which run showed it.
26
26
  - A note that was only ever true for one day (a count, a cursor, "today"):
27
- move it to `../../state/` if it is still useful, else delete it.
27
+ move it to `workspace/state/` if it is still useful, else delete it.
28
28
  - Anything you are not sure about: leave it, and list it in your report.
29
29
  3. Never write a fact you did not find in a file or a run. Never touch
30
30
  `knowledge/` — that is what people gave the agents, and it is theirs.
@@ -18,7 +18,7 @@ Do exactly this, in order:
18
18
  again. Save the final text to `outputs/note.md`.
19
19
  3. Write one thing you learned to `../../memory/` as a new file — one fact,
20
20
  plain markdown, a sentence or two. The platform records who wrote it.
21
- 4. Update `../../state/publishing.md`: increase the note count by one and set
21
+ 4. Update `workspace/state/publishing.md`: increase the note count by one and set
22
22
  the last topic to what you wrote about. Keep it as markdown.
23
23
 
24
24
  Then stop. Do not create anything else.