@k2b/cloud 0.30.0 → 0.31.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 (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
@@ -3,6 +3,7 @@ import { compact, nessi } from "@k2b/nessi";
3
3
  import { listCapabilities } from "../_internal/registry";
4
4
  import type { CapabilityActionReview } from "../contracts/capabilities";
5
5
  import type { AccessSubject, RequestActor } from "../server";
6
+ import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
6
7
  import { createHelpReader } from "../services/help";
7
8
  import { logger } from "../services/logging";
8
9
  import { getMandate } from "../services/mandates";
@@ -10,6 +11,7 @@ import type { MandatePolicyV1 } from "../services/mandates/policy";
10
11
  import { coreSettings } from "../services/settings/api";
11
12
  import { normalizeLocale } from "../shared/locale";
12
13
  import {
14
+ AI_WEBSITE_APPROVAL_TOOL,
13
15
  type AiToolApprovalContext,
14
16
  aiToolAllowsAlways,
15
17
  aiToolApprovalScope,
@@ -55,13 +57,13 @@ import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
55
57
  import { selectAiSkillCatalog } from "./skill-catalog";
56
58
  import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool, loadSelectedAiSkills } from "./skill-tool";
57
59
  import { aiSkills } from "./skills";
58
- import { aiConversations } from "./store";
60
+ import { aiConversations, recordAiWebsiteReceipts } from "./store";
59
61
  import { AI_LIVE_SNAPSHOT_INTERVAL_MS, publishAiWireEvent } from "./stream";
60
62
  import { composeAiSystemPrompt } from "./system-prompt";
61
63
  import { aiToolAudit } from "./tool-audit";
62
64
  import { acceptCanonicalToolNames } from "./tool-call-names";
63
65
  import { resolveAiToolResultMaxChars } from "./tool-result-budget";
64
- import { aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
66
+ import { type AiApprovalUse, aiApprovalAskedLater, aiToolPromptHints, type PreparedAiTools, prepareAiTools } from "./tools";
65
67
  import { AiTranscriptionError } from "./transcription";
66
68
  import {
67
69
  AiTurnFailure,
@@ -75,6 +77,7 @@ import { type AiTurnGuidance, loadAiTurnGuidance } from "./turn-guidance";
75
77
  import { type AiTurnPolicyToolCall, applyAiTurnPolicy } from "./turn-policy";
76
78
  import { createTurnTimingRecorder, withDurableTurnTiming } from "./turn-timing";
77
79
  import type {
80
+ AiApprovalTarget,
78
81
  AiChatTurnRunConfig,
79
82
  AiFrontendToolMode,
80
83
  AiPendingTurnActionRecord,
@@ -85,6 +88,7 @@ import type {
85
88
  AiTurnFinalizedEvent,
86
89
  AiTurnRunConfig,
87
90
  AiTurnSteer,
91
+ AiWebsiteReceipt,
88
92
  } from "./types";
89
93
  import { isAiImageMediaType } from "./types";
90
94
  import { validateAiTurnRequest } from "./validate";
@@ -296,7 +300,15 @@ const rebuildBlocksFromMessages = (
296
300
  callId: action.callId,
297
301
  status: action.kind === "client_tool" ? "awaiting_client" : "awaiting_approval",
298
302
  approval:
299
- action.kind === "client_tool" ? undefined : { message: action.message, review: action.review, allowAlways: action.allowAlways },
303
+ action.kind === "client_tool"
304
+ ? undefined
305
+ : {
306
+ message: action.message,
307
+ review: action.review,
308
+ allowAlways: action.allowAlways,
309
+ allowChat: action.allowChat ?? action.allowAlways,
310
+ ...(action.rememberToolName === AI_WEBSITE_APPROVAL_TOOL ? { website: action.approvalScope } : {}),
311
+ },
300
312
  frontendMode: action.frontendMode,
301
313
  };
302
314
  }
@@ -381,9 +393,25 @@ const approvalReviewForCallId = (
381
393
  */
382
394
  const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowRememberedApprovals = true) => {
383
395
  const toolBlocks = new Map<string, Extract<AiTurnBlock, { kind: "tool" }>>();
396
+ /** Website receipts by the call that shows them and their index; every later state of that call keeps them. */
397
+ const receiptsByCall = new Map<string, Map<number, { method: string; url: string }>>();
384
398
  for (const block of seedBlocks) {
385
- if (block.kind === "tool") toolBlocks.set(block.callId, block);
399
+ if (block.kind !== "tool") continue;
400
+ toolBlocks.set(block.callId, block);
401
+ // A pending nested approval shows on its parent call's block; receipts belong to that block.
402
+ const shownCallId = block.id === toolBlockId(block.callId) ? block.callId : (customApprovalParentCallId(block.callId) ?? block.callId);
403
+ if (block.receipts?.length) receiptsByCall.set(shownCallId, new Map(block.receipts.map((receipt, index) => [index, receipt])));
386
404
  }
405
+ const receiptsOf = (callId: string) => {
406
+ const receipts = receiptsByCall.get(callId);
407
+ return receipts ? [...receipts].sort(([a], [b]) => a - b).map(([, receipt]) => receipt) : undefined;
408
+ };
409
+ const addReceipts = (callId: string, receipts: AiWebsiteReceipt[]) => {
410
+ const known = receiptsByCall.get(callId) ?? new Map<number, { method: string; url: string }>();
411
+ for (const { index, method, url } of receipts) known.set(index, { method, url });
412
+ receiptsByCall.set(callId, known);
413
+ return receiptsOf(callId) ?? [];
414
+ };
387
415
  /** Real frontend mode per tool name — set once the turn's tools are prepared.
388
416
  * Getting this wrong is not cosmetic: the client auto-resolves plain "client"
389
417
  * blocks, so a mislabeled client_interaction tool (survey) would be answered
@@ -408,6 +436,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
408
436
  const setApprovalPolicies = (items: PreparedAiTools["approvalPolicies"]) => {
409
437
  approvalPolicies = items;
410
438
  };
439
+ let approvalTargets: ReadonlyMap<string, AiApprovalTarget> = new Map();
440
+ const setApprovalTargets = (items: ReadonlyMap<string, AiApprovalTarget>) => {
441
+ approvalTargets = items;
442
+ };
411
443
  let rejectedCallIds = new Set<string>();
412
444
  const setRejectedCallIds = (items: Set<string>) => {
413
445
  rejectedCallIds = items;
@@ -435,10 +467,11 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
435
467
  status: patch.status ?? existing?.status ?? "running",
436
468
  result: "result" in patch ? patch.result : existing?.result,
437
469
  isError: "isError" in patch ? patch.isError : existing?.isError,
438
- approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false } : approval,
470
+ approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
439
471
  frontendMode: patch.frontendMode ?? existing?.frontendMode,
440
472
  presentation: patch.presentation ?? existing?.presentation ?? presentations.get(rawName) ?? presentations.get(name),
441
473
  ...(approvedCallIds.has(callId) || approvedCallIds.has(displayCallId) || existing?.approved ? { approved: true } : {}),
474
+ ...(receiptsOf(displayCallId) ? { receipts: receiptsOf(displayCallId) } : {}),
442
475
  };
443
476
  toolBlocks.set(callId, block);
444
477
  return { type: "block_set", block };
@@ -484,6 +517,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
484
517
  case "tool_action_request":
485
518
  const displayCallId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
486
519
  const review = approvalReviewForCallId(approvalReviews, event.callId);
520
+ const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
521
+ const allowAlways = target
522
+ ? target.always
523
+ : review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name));
487
524
  return [
488
525
  setTool(
489
526
  event.callId,
@@ -497,7 +534,9 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
497
534
  : {
498
535
  message: event.message,
499
536
  review,
500
- allowAlways: review?.approvalScope !== undefined || aiToolAllowsAlways(approvalPolicies.get(event.name)),
537
+ allowAlways,
538
+ allowChat: target !== undefined || allowAlways,
539
+ ...(target?.toolName === AI_WEBSITE_APPROVAL_TOOL ? { website: target.approvalScope } : {}),
501
540
  },
502
541
  frontendMode: event.kind === "client_tool" ? (frontendModes.get(event.name) ?? "client") : undefined,
503
542
  },
@@ -542,8 +581,10 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
542
581
  setCanonicalNames,
543
582
  setApprovalPolicies,
544
583
  setApprovalReviews,
584
+ setApprovalTargets,
545
585
  setRejectedCallIds,
546
586
  setApprovedCallIds,
587
+ addReceipts,
547
588
  };
548
589
  };
549
590
 
@@ -900,9 +941,13 @@ export class AiTurnExecutor {
900
941
  });
901
942
  }
902
943
 
944
+ const approvalTargets = new Map<string, AiApprovalTarget>();
945
+ const linkedApprovals = new Map<string, string>();
946
+ const freshApprovalRequired = new Set<string>();
903
947
  const dynamicToolRuntimeContext = {
904
948
  turnId,
905
949
  reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
950
+ reportWebsiteReceipts: (callId: string, receipts: AiWebsiteReceipt[]) => pipeline.reportWebsiteReceipts(callId, receipts),
906
951
  attachedFilePaths: new Set(config.files?.attached.map((file) => file.path) ?? []),
907
952
  allowedDataBoundaries: material.modelPolicy?.allowedDataBoundaries,
908
953
  projectFiles,
@@ -910,6 +955,35 @@ export class AiTurnExecutor {
910
955
  selectedModel: resolved,
911
956
  locale: promptLocale,
912
957
  timeZone,
958
+ linkApproval: (approvalCallId: string, approvalId: string) => {
959
+ linkedApprovals.set(approvalCallId, approvalId);
960
+ },
961
+ consumeApproval: async (approvalCallId: string, input: unknown): Promise<AiApprovalUse> => {
962
+ const pending = await aiConversations.getPendingTurnAction({ conversationId, turnId, callId: approvalCallId });
963
+ if (!pending?.approvalId) return "approved";
964
+ const result = await approvals.consume(pending.approvalId, {
965
+ input,
966
+ review: approvalReviewForCallId(capabilityActionReviews, approvalCallId) ?? null,
967
+ requester: { kind: "assistant_turn", ref: `${turnId}/${approvalCallId}` },
968
+ });
969
+ if (result.ok) return "approved";
970
+ if (result.code === "ALREADY_CONSUMED") {
971
+ // A later question of the same call proves this run resumes after it, not after a crash during the effect.
972
+ const [open, resolved] = await Promise.all([
973
+ aiConversations.listPendingActionRecords({ conversationId, turnId }),
974
+ aiConversations.listResolvedPendingActions({ conversationId, turnId }),
975
+ ]);
976
+ return aiApprovalAskedLater(
977
+ approvalCallId,
978
+ [...open, ...resolved].map((action) => action.callId),
979
+ )
980
+ ? "approved"
981
+ : "used";
982
+ }
983
+ freshApprovalRequired.add(customApprovalParentCallId(approvalCallId) ?? approvalCallId);
984
+ return "changed";
985
+ },
986
+ describeApproval: (approvalCallId: string, target: AiApprovalTarget) => approvalTargets.set(approvalCallId, target),
913
987
  };
914
988
  const prepared = prepareAiTools({
915
989
  tools: activeTools,
@@ -920,6 +994,7 @@ export class AiTurnExecutor {
920
994
  let backgroundError: string | null = null;
921
995
  const rememberableCapabilityApprovals = new Map<string, string>();
922
996
  const capabilityActionReviews = new Map<string, CapabilityActionReview>();
997
+ pipeline.setApprovalTargets(approvalTargets);
923
998
  pipeline.setFrontendModes(prepared.frontendModes);
924
999
  pipeline.setCanonicalNames(prepared.canonicalNames);
925
1000
  pipeline.setApprovalPolicies(prepared.approvalPolicies);
@@ -1332,6 +1407,9 @@ export class AiTurnExecutor {
1332
1407
  allowRememberedApprovals: aiTurnAllowsRememberedApprovals(config),
1333
1408
  rememberableCapabilityApprovals,
1334
1409
  capabilityActionReviews,
1410
+ approvalTargets,
1411
+ linkedApprovals,
1412
+ freshApprovalRequired,
1335
1413
  appliedSteers,
1336
1414
  noteToolRound: turnPolicy.noteToolRound,
1337
1415
  noteToolCall: turnPolicy.noteToolCall,
@@ -1401,6 +1479,9 @@ export class AiTurnExecutor {
1401
1479
  allowRememberedApprovals: boolean;
1402
1480
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1403
1481
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1482
+ approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1483
+ linkedApprovals: ReadonlyMap<string, string>;
1484
+ freshApprovalRequired?: ReadonlySet<string>;
1404
1485
  appliedSteers: AiTurnSteer[];
1405
1486
  noteToolRound: () => void;
1406
1487
  noteToolCall: (call: AiTurnPolicyToolCall) => void;
@@ -1423,6 +1504,7 @@ export class AiTurnExecutor {
1423
1504
  allowRememberedApprovals,
1424
1505
  rememberableCapabilityApprovals,
1425
1506
  capabilityActionReviews,
1507
+ approvalTargets,
1426
1508
  appliedSteers,
1427
1509
  noteToolRound,
1428
1510
  noteToolCall,
@@ -1446,6 +1528,9 @@ export class AiTurnExecutor {
1446
1528
  allowRememberedApprovals,
1447
1529
  rememberableCapabilityApprovals,
1448
1530
  capabilityActionReviews,
1531
+ approvalTargets,
1532
+ linkedApprovals: input.linkedApprovals,
1533
+ freshApprovalRequired: input.freshApprovalRequired,
1449
1534
  onBackgroundBlocked: input.onBackgroundBlocked,
1450
1535
  });
1451
1536
  if (suspended) {
@@ -1549,6 +1634,9 @@ export class AiTurnExecutor {
1549
1634
  allowRememberedApprovals: boolean;
1550
1635
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1551
1636
  capabilityActionReviews: ReadonlyMap<string, CapabilityActionReview>;
1637
+ approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1638
+ linkedApprovals: ReadonlyMap<string, string>;
1639
+ freshApprovalRequired?: ReadonlySet<string>;
1552
1640
  onBackgroundBlocked?: (message: string) => void;
1553
1641
  }): Promise<boolean> {
1554
1642
  const {
@@ -1562,6 +1650,7 @@ export class AiTurnExecutor {
1562
1650
  allowRememberedApprovals,
1563
1651
  rememberableCapabilityApprovals,
1564
1652
  capabilityActionReviews,
1653
+ approvalTargets,
1565
1654
  } = input;
1566
1655
  const approvalPolicy = prepared.approvalPolicies.get(event.name);
1567
1656
  const toolName = prepared.canonicalNames.get(event.name) ?? event.name;
@@ -1571,8 +1660,13 @@ export class AiTurnExecutor {
1571
1660
  event.kind === "custom_approval"
1572
1661
  ? rememberableCapabilityApprovals.get(customApprovalParentCallId(event.callId) ?? event.callId)
1573
1662
  : undefined;
1574
- const approvalScope = capabilityApprovalScope ?? aiToolApprovalScope(toolName, approvalPolicy);
1575
- const allowAlways = allowRememberedApprovals && (capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy));
1663
+ // A tool that names what it asks for (a code run's HTTP request or Action, a web page to read) already looked up
1664
+ // its own remembered approvals under its own rules; the approval is remembered for that target, not the tool.
1665
+ const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
1666
+ const approvalScope = target?.approvalScope ?? capabilityApprovalScope ?? aiToolApprovalScope(toolName, approvalPolicy);
1667
+ const allowAlways =
1668
+ allowRememberedApprovals && (target ? target.always : capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy));
1669
+ const allowChat = allowRememberedApprovals && (target !== undefined || allowAlways);
1576
1670
 
1577
1671
  // Display-only client_view tools (cards, charts) need neither a browser nor input: Cloud answers them itself, in
1578
1672
  // background runs too, whose transcript shows them later. The block goes from running straight to completed:
@@ -1609,8 +1703,15 @@ export class AiTurnExecutor {
1609
1703
  }
1610
1704
 
1611
1705
  // Remembered approvals resolve inline too.
1612
- if (event.kind !== "client_tool" && allowAlways && approvalContext) {
1613
- const remembered = await hasRememberedAiToolApproval(approvalContext, { toolName, approvalScope }).catch(() => false);
1706
+ if (
1707
+ event.kind !== "client_tool" &&
1708
+ !target &&
1709
+ allowAlways &&
1710
+ approvalContext &&
1711
+ // A stale decided snapshot needs another human decision, even if the first response also remembered it.
1712
+ !input.freshApprovalRequired?.has(customApprovalParentCallId(event.callId) ?? event.callId)
1713
+ ) {
1714
+ const remembered = await hasRememberedAiToolApproval(approvalContext, { toolName, approvalScope, conversationId }).catch(() => false);
1614
1715
  if (remembered) {
1615
1716
  await aiToolAudit
1616
1717
  .noteApprovalResolved({ turnId, callId: event.callId, approvalState: "approved_by_preference" })
@@ -1620,7 +1721,40 @@ export class AiTurnExecutor {
1620
1721
  }
1621
1722
  }
1622
1723
 
1724
+ const review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
1725
+ let approvalId: string | undefined;
1726
+ let presented = event;
1727
+ if (event.kind !== "client_tool") {
1728
+ const conversation = await aiConversations.getConversation({ conversationId });
1729
+ const owner = conversation?.createdByUserId;
1730
+ if (!owner) throw new Error("Approval requires a conversation owner");
1731
+ const linked = input.linkedApprovals.get(event.callId);
1732
+ if (linked) {
1733
+ const record = await approvals.get(linked);
1734
+ if (!record || record.status !== "pending" || record.requesterKind !== "studio_app" || record.approverUserId !== owner)
1735
+ throw new Error("Approval does not belong to an open Studio request of the conversation owner");
1736
+ approvalId = linked;
1737
+ // The card shows the question the effect's own record holds, never the text the relay sent.
1738
+ presented = { ...event, message: record.sentence };
1739
+ } else {
1740
+ const record = await approvals.request({
1741
+ ...approvalScopeFromToolName(toolName),
1742
+ approvalScope,
1743
+ requester: { kind: "assistant_turn", ref: `${turnId}/${event.callId}`, appId: "assistant", title: conversation.title },
1744
+ subjectId: owner,
1745
+ approverUserId: owner,
1746
+ assurance: approvalAssurance(target !== undefined || capabilityApprovalScope !== undefined || aiToolAllowsAlways(approvalPolicy)),
1747
+ sentence: event.message ?? toolName,
1748
+ review,
1749
+ input: event.args,
1750
+ expiresInMs: AI_ACTION_BUDGET_MS,
1751
+ });
1752
+ if (!record.ok) throw new Error("This approval was already decided or has expired.");
1753
+ approvalId = record.id;
1754
+ }
1755
+ }
1623
1756
  await aiConversations.savePendingTurnAction({
1757
+ approvalId,
1624
1758
  turnId,
1625
1759
  conversationId,
1626
1760
  callId: event.callId,
@@ -1628,10 +1762,12 @@ export class AiTurnExecutor {
1628
1762
  status: "pending",
1629
1763
  name: toolName,
1630
1764
  args: event.args,
1631
- message: event.message,
1632
- review: event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined,
1765
+ message: presented.message,
1766
+ review,
1633
1767
  approvalScope,
1634
1768
  allowAlways,
1769
+ allowChat,
1770
+ ...(target ? { rememberToolName: target.toolName } : {}),
1635
1771
  frontendMode,
1636
1772
  resolvedEvent: null,
1637
1773
  });
@@ -1653,7 +1789,7 @@ export class AiTurnExecutor {
1653
1789
  .catch(() => undefined);
1654
1790
  }
1655
1791
 
1656
- await pipeline.apply(event);
1792
+ await pipeline.apply(presented);
1657
1793
  const suspended = await aiConversations.suspendTurn({
1658
1794
  conversationId,
1659
1795
  turnId,
@@ -1871,7 +2007,9 @@ class StreamPipeline {
1871
2007
  this.blocks = this.allowRememberedApprovals
1872
2008
  ? blocks
1873
2009
  : blocks.map((block) =>
1874
- block.kind === "tool" && block.approval ? { ...block, approval: { ...block.approval, allowAlways: false } } : block,
2010
+ block.kind === "tool" && block.approval
2011
+ ? { ...block, approval: { ...block.approval, allowAlways: false, allowChat: false } }
2012
+ : block,
1875
2013
  );
1876
2014
  }
1877
2015
 
@@ -1895,6 +2033,10 @@ class StreamPipeline {
1895
2033
  this.mapper.setApprovalPolicies(policies);
1896
2034
  }
1897
2035
 
2036
+ setApprovalTargets(targets: ReadonlyMap<string, AiApprovalTarget>): void {
2037
+ this.mapper.setApprovalTargets(targets);
2038
+ }
2039
+
1898
2040
  setRejectedCallIds(callIds: Set<string>): void {
1899
2041
  this.mapper.setRejectedCallIds(callIds);
1900
2042
  }
@@ -1950,6 +2092,20 @@ class StreamPipeline {
1950
2092
  await this.maybeSnapshot();
1951
2093
  }
1952
2094
 
2095
+ /**
2096
+ * Shows requests a website approval let through on the call's block, and records them on its message before they go
2097
+ * out, so the chat keeps each receipt and its revoke action whether the call succeeds, fails, or the turn stops.
2098
+ */
2099
+ async reportWebsiteReceipts(callId: string, receipts: AiWebsiteReceipt[]): Promise<void> {
2100
+ if (!receipts.length) return;
2101
+ await recordAiWebsiteReceipts({ conversationId: this.conversationId, turnId: this.turnId, callId, receipts });
2102
+ const merged = this.mapper.addReceipts(callId, receipts);
2103
+ const block = this.blocks.find((block) => block.kind === "tool" && block.id === toolBlockId(callId));
2104
+ if (block?.kind !== "tool") return;
2105
+ await this.emitOp({ type: "block_set", block: { ...block, receipts: merged } });
2106
+ await this.maybeSnapshot();
2107
+ }
2108
+
1953
2109
  async reportToolProgress(callId: string, progress: string): Promise<void> {
1954
2110
  const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
1955
2111
  if (!block || block.kind !== "tool" || block.progress === progress) return;
@@ -3,6 +3,7 @@ import { type RequestOptions as HttpsRequestOptions, request as httpsRequest } f
3
3
  import { z } from "zod";
4
4
  import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../services/network-security";
5
5
  import { AI_FILES_MAX_FILE_BYTES_DEFAULT, aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
6
+ import { authorizeWebRead } from "./firecrawl-tools";
6
7
  import { defineAiTool } from "./tools";
7
8
 
8
9
  const FETCH_FILE_MAX_REDIRECTS = 5;
@@ -225,9 +226,12 @@ const responseLocation = (headers: IncomingHttpHeaders): string | null => {
225
226
  return typeof value === "string" && value.trim() ? value : null;
226
227
  };
227
228
 
229
+ /** How a download that a website approval let through may proceed: only on that origin, each request shown first. */
230
+ type WebsiteApprovedDownload = { origin: string; onRequest: (url: URL, hop: number) => Promise<void> };
231
+
228
232
  export const downloadPublicFile = async (
229
233
  rawUrl: string,
230
- options: FetchFileDependencies & { maxBytes?: number; signal?: AbortSignal } = {},
234
+ options: FetchFileDependencies & { maxBytes?: number; signal?: AbortSignal; approvedWebsite?: WebsiteApprovedDownload } = {},
231
235
  ): Promise<{ bytes: Uint8Array; headers: IncomingHttpHeaders; url: URL }> => {
232
236
  const resolve = options.resolve ?? resolvePublicNetworkAddresses;
233
237
  const perform =
@@ -238,6 +242,14 @@ export const downloadPublicFile = async (
238
242
  let url = publicHttpsUrl(rawUrl);
239
243
 
240
244
  for (let redirects = 0; redirects <= FETCH_FILE_MAX_REDIRECTS; redirects++) {
245
+ if (options.approvedWebsite) {
246
+ // A website approval covers its own origin only; an open redirect must not carry the address to another one.
247
+ if (url.origin !== options.approvedWebsite.origin)
248
+ throw new Error(
249
+ `Redirect to another website — The file moved to ${url.toString()}, which this chat has not allowed. Download that address directly so the person can decide.`,
250
+ );
251
+ await options.approvedWebsite.onRequest(url, redirects);
252
+ }
241
253
  const addresses = await resolveFileAddresses(resolve, url.hostname);
242
254
  const response = await perform(url, addresses, { maxBytes, signal: options.signal });
243
255
  if (response.statusCode >= 300 && response.statusCode < 400) {
@@ -280,11 +292,15 @@ const responseMediaType = (headers: IncomingHttpHeaders, path: string): string =
280
292
 
281
293
  export const runCloudAiFetchFile = async (
282
294
  input: z.infer<typeof CloudAiFetchFileInputSchema>,
283
- context: { conversationId?: string; signal?: AbortSignal },
295
+ context: { conversationId?: string; signal?: AbortSignal; approvedWebsite?: WebsiteApprovedDownload },
284
296
  dependencies: FetchFileDependencies = {},
285
297
  ): Promise<z.infer<typeof CloudAiFetchFileOutputSchema>> => {
286
298
  if (!context.conversationId) throw new Error("The fetch_file tool needs a conversation context.");
287
- const downloaded = await downloadPublicFile(input.url, { ...dependencies, signal: context.signal });
299
+ const downloaded = await downloadPublicFile(input.url, {
300
+ ...dependencies,
301
+ signal: context.signal,
302
+ approvedWebsite: context.approvedWebsite,
303
+ });
288
304
  const path = normalizeAiFilePath(`/imports/${safeFilename(input.filename, downloaded.url)}`);
289
305
  if (!path) throw new Error("Could not derive a valid conversation file path.");
290
306
  const stat = await aiFileStore.createAssistantFile({
@@ -307,7 +323,28 @@ export const createCloudAiFetchFileTool = (dependencies: FetchFileDependencies =
307
323
  timeoutMs: 90_000,
308
324
  promptHint:
309
325
  "fetch an exact public HTTPS source file into the chat when the user provides a file link; inspect it with read_file or view_image and present it when useful.",
310
- }).server((input, context) => runCloudAiFetchFile(input, context, dependencies));
326
+ }).server(async (input, context) => {
327
+ const url = publicHttpsUrl(input.url);
328
+ const allowedForChat = await authorizeWebRead("file", url.toString(), context);
329
+ return runCloudAiFetchFile(
330
+ input,
331
+ {
332
+ ...context,
333
+ ...(allowedForChat
334
+ ? {
335
+ approvedWebsite: {
336
+ origin: url.origin,
337
+ // The first request's receipt is already shown; each redirect on the same website adds one.
338
+ onRequest: async (next: URL, hop: number) => {
339
+ if (hop > 0) await context.reportWebsiteReceipts?.([{ index: hop, method: "GET", url: next.toString() }]);
340
+ },
341
+ },
342
+ }
343
+ : {}),
344
+ },
345
+ dependencies,
346
+ );
347
+ });
311
348
 
312
349
  export type CloudAiFetchFileInput = z.infer<typeof CloudAiFetchFileInputSchema>;
313
350
  export type CloudAiFetchFileOutput = z.infer<typeof CloudAiFetchFileOutputSchema>;
@@ -1,8 +1,13 @@
1
1
  import { isIP } from "node:net";
2
- import { truncateMiddle } from "@k2b/nessi";
2
+ import { type ToolContext, truncateMiddle } from "@k2b/nessi";
3
3
  import { z } from "zod";
4
+ import type { RequestActor } from "../server";
4
5
  import { coreSettings } from "../services";
6
+ import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsWebsiteApprovals, aiWebsiteApprovalScope, findRememberedAiToolApproval } from "./approvals";
7
+ import { aiConversations } from "./store";
5
8
  import { defineAiTool } from "./tools";
9
+ import type { AiApprovalTarget, AiWebsiteReceipt } from "./types";
10
+ import { hasWebAddressProvenance, recordWebAddresses, webAddressesIn } from "./web-provenance";
6
11
 
7
12
  export const AI_FIRECRAWL_API_KEY_SETTING_KEY = "ai.firecrawl_api_key";
8
13
 
@@ -274,13 +279,70 @@ export const createCloudAiWebSearchTool = (config: FirecrawlToolConfig = {}) =>
274
279
  ...result,
275
280
  snippet: truncateSnippet(result.snippet),
276
281
  })),
277
- }).server(async (input, ctx) => runCloudAiWebSearch(input, { ...config, signal: ctx.signal }));
282
+ }).server(async (input, ctx) => {
283
+ const results = await runCloudAiWebSearch(input, { ...config, signal: ctx.signal });
284
+ if (ctx.conversationId)
285
+ await recordWebAddresses(
286
+ ctx.conversationId,
287
+ results.flatMap((result) => [...webAddressesIn(result.url)]),
288
+ );
289
+ return results;
290
+ });
291
+
292
+ const webReadApprovalMessage = (kind: "page" | "file", url: string, locale: string | undefined) =>
293
+ locale?.startsWith("de")
294
+ ? [
295
+ `${kind === "page" ? "Webseite lesen" : "Datei laden"}: ${url}`,
296
+ "Diese Adresse steht nicht in deiner Nachricht, in einem Suchergebnis oder als Link einer gelesenen Seite.",
297
+ "Die vollständige Adresse geht an die Website.",
298
+ ].join("\n")
299
+ : [
300
+ `${kind === "page" ? "Read web page" : "Download file"}: ${url}`,
301
+ "This address is not in your message, in a search result, or linked from a page read earlier.",
302
+ "The full address goes to the website.",
303
+ ].join("\n");
304
+
305
+ export type WebReadContext = Pick<ToolContext, "requestApproval"> & {
306
+ actor: RequestActor;
307
+ conversationId?: string;
308
+ turnId?: string;
309
+ locale?: string;
310
+ requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
311
+ reportWebsiteReceipts?: (receipts: AiWebsiteReceipt[]) => Promise<void>;
312
+ };
313
+
314
+ /**
315
+ * Lets a public web read through when the chat supplied its address. A read sends its full address to the website,
316
+ * and an address the chat did not supply may carry private data the model put into it, so any other address asks
317
+ * like an HTTP request from code: with the full URL, and with the website approval of a signed-in chat. Returns
318
+ * whether such an approval let it through without asking; the chat then shows the receipt before the read goes out.
319
+ * Throws when the person declines.
320
+ */
321
+ export const authorizeWebRead = async (kind: "page" | "file", url: string, ctx: WebReadContext): Promise<boolean> => {
322
+ if (ctx.conversationId && (await hasWebAddressProvenance(ctx.conversationId, url))) return false;
323
+ const target = { toolName: AI_WEBSITE_APPROVAL_TOOL, approvalScope: aiWebsiteApprovalScope(new URL(url).origin), always: false };
324
+ const runConfig = ctx.conversationId
325
+ ? await aiConversations.getTurnRunConfig({ conversationId: ctx.conversationId, turnId: ctx.turnId ?? "" }).catch(() => null)
326
+ : null;
327
+ const actorUserId = ctx.actor.kind === "user" ? ctx.actor.user.id : (ctx.actor.delegatedUser?.id ?? "");
328
+ if (
329
+ aiTurnAllowsWebsiteApprovals(runConfig) &&
330
+ (await findRememberedAiToolApproval({ actorUserId }, { ...target, conversationId: ctx.conversationId, chatOnly: true })) !== null
331
+ ) {
332
+ await ctx.reportWebsiteReceipts?.([{ index: 0, method: "GET", url }]);
333
+ return true;
334
+ }
335
+ const message = webReadApprovalMessage(kind, url, ctx.locale);
336
+ const approved = ctx.requestApprovalFor ? await ctx.requestApprovalFor(message, target) : await ctx.requestApproval(message);
337
+ if (!approved) throw new Error("The person did not allow reading this address. Use an address from their message or a search result.");
338
+ return false;
339
+ };
278
340
 
279
341
  export const createCloudAiWebExtractTool = (config: FirecrawlToolConfig = {}) =>
280
342
  defineAiTool({
281
343
  name: "web_extract",
282
344
  description:
283
- "Read one web page by URL and return clean Markdown. Inspect the relevant pages needed to support the answer; for research or comparison, prefer primary sources and read more than one useful source when warranted.",
345
+ "Read one web page by URL and return clean Markdown. Inspect the relevant pages needed to support the answer; for research or comparison, prefer primary sources and read more than one useful source when warranted. Use addresses exactly as the user wrote them, as web_search returned them, or as a page you already read links them; any other address asks the user first.",
284
346
  inputSchema: CloudAiWebExtractInputSchema,
285
347
  outputSchema: CloudAiWebExtractOutputSchema,
286
348
  approval: "never",
@@ -297,7 +359,28 @@ export const createCloudAiWebExtractTool = (config: FirecrawlToolConfig = {}) =>
297
359
  truncated: output.truncated || content !== output.content,
298
360
  };
299
361
  },
300
- }).server(async (input, ctx) => runCloudAiWebExtract(input, { ...config, signal: ctx.signal }));
362
+ }).server(async (input, ctx) => {
363
+ const url = assertPublicHttpUrl(input.url);
364
+ const allowedForChat = await authorizeWebRead("page", url, ctx);
365
+ const output = await runCloudAiWebExtract({ url }, { ...config, signal: ctx.signal });
366
+ const final = URL.canParse(output.url) ? new URL(output.url) : null;
367
+ if (allowedForChat && final && final.href !== url) {
368
+ // Firecrawl follows redirects on its side. The chat shows where the read ended, and a website approval covers
369
+ // only content from its own origin.
370
+ await ctx.reportWebsiteReceipts?.([{ index: 1, method: "GET", url: final.href }]);
371
+ if (final.origin !== new URL(url).origin)
372
+ throw new Error(
373
+ `The page redirected to ${final.href}, which this chat has not allowed. Read that address directly so the person can decide.`,
374
+ );
375
+ }
376
+ // The page and every address it links to may be read again without asking.
377
+ if (ctx.conversationId)
378
+ await recordWebAddresses(
379
+ ctx.conversationId,
380
+ new Set([...webAddressesIn(url), ...webAddressesIn(output.url), ...webAddressesIn(output.content)]),
381
+ );
382
+ return output;
383
+ });
301
384
 
302
385
  export type CloudAiWebSearchInput = z.infer<typeof CloudAiWebSearchInputSchema>;
303
386
  export type CloudAiWebSearchOutput = z.infer<typeof CloudAiWebSearchOutputSchema>;
package/src/ai/index.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  export { type AiAppSkillIssue, aiAppSkills, appSkillsRegistryState, reconcileAppSkills } from "./app-skill-store";
2
2
  export { type AiApprovalPreferenceRoutes, type AiApprovalPreferenceView, createAiApprovalPreferenceRoutes } from "./approval-routes";
3
3
  export {
4
+ AI_WEBSITE_APPROVAL_TOOL,
4
5
  type AiToolApprovalContext,
5
6
  type AiToolApprovalPreference,
7
+ type AiToolApprovalRemember,
6
8
  aiToolAllowsAlways,
7
9
  aiToolApprovalScope,
8
10
  aiToolNeedsApproval,
11
+ aiTurnAllowsWebsiteApprovals,
12
+ aiWebsiteApprovalScope,
13
+ findRememberedAiToolApproval,
9
14
  forgetAiToolApproval,
10
15
  hasRememberedAiToolApproval,
11
16
  listAiToolApprovalPreferences,
17
+ parseAiWebsiteApprovalScope,
12
18
  rememberAiToolApproval,
13
19
  revokeAiToolApprovalPreference,
14
20
  } from "./approvals";
@@ -309,6 +315,7 @@ export {
309
315
  } from "./transcription";
310
316
  export type {
311
317
  AiAccessResult,
318
+ AiApprovalTarget,
312
319
  AiCapabilityToolPresentation,
313
320
  AiClientToolId,
314
321
  AiConversation,
@@ -360,6 +367,7 @@ export type {
360
367
  AiTurnStatus,
361
368
  AiTurnToolSource,
362
369
  AiUserContentPart,
370
+ AiWebsiteReceipt,
363
371
  } from "./types";
364
372
  export { AI_MESSAGE_FEEDBACK_REASONS, isAiImageMediaType } from "./types";
365
373
  export { isAiSettingsError } from "./validate";