@k2b/cloud 0.41.0 → 0.42.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 (227) hide show
  1. package/package.json +6 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -1,9 +1,9 @@
1
1
  import type { AssistantMessage, CompactEvent, LoopAggregate, Message, NessiLoop, OutboundEvent } from "@k2b/nessi";
2
2
  import { compact, nessi } from "@k2b/nessi";
3
3
  import { capabilityReviewRememberScope } from "../_internal/capability-reviews";
4
- import { listCapabilities } from "../_internal/registry";
4
+ import { getCapability, listCapabilities } from "../_internal/registry";
5
5
  import type { CapabilityActionReviewData } from "../contracts/capabilities";
6
- import { capabilityActionPolicy } from "../contracts/capabilities";
6
+ import { CapabilityActionReviewDataSchema, capabilityActionPolicy } from "../contracts/capabilities";
7
7
  import type { AccessSubject, RequestActor } from "../server";
8
8
  import { approvalAssurance, approvalScopeFromToolName, approvals } from "../services/approvals";
9
9
  import { createHelpReader } from "../services/help";
@@ -73,6 +73,7 @@ import {
73
73
  import { retryTransientProviderErrors } from "./provider-retry";
74
74
  import { assistantQuotaProvider, inferenceProvider } from "./quota-provider";
75
75
  import { collectConversationResourceObservations } from "./resource-refs";
76
+ import { compactCapabilityReview } from "./review-content";
76
77
  import { AiRunTimeout } from "./run-timeout";
77
78
  import { isAiVisionModelConfigured, type resolveAiModel } from "./settings";
78
79
  import { mergeAiSkillCatalog, selectAiSkillCatalog } from "./skill-catalog";
@@ -456,7 +457,12 @@ const approvalReviewForCallId = (
456
457
  * to (attempt, turn) so re-claimed attempts never collide, and (b) maintains
457
458
  * tool blocks keyed by callId, enriched with Cloud status/approval metadata.
458
459
  */
459
- const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowRememberedApprovals = true) => {
460
+ const createEventMapper = (
461
+ attempt: number,
462
+ seedBlocks: AiTurnBlock[],
463
+ allowRememberedApprovals = true,
464
+ location?: { conversationId: string; turnId: string },
465
+ ) => {
460
466
  const toolBlocks = new Map<string, Extract<AiTurnBlock, { kind: "tool" }>>();
461
467
  /** Website receipts by the call that shows them and their index; every later state of that call keeps them. */
462
468
  const receiptsByCall = new Map<string, Map<number, { method: string; url: string }>>();
@@ -586,7 +592,8 @@ const createEventMapper = (attempt: number, seedBlocks: AiTurnBlock[], allowReme
586
592
  return [setTool(event.callId, { name: event.name, args: event.args, status: "running" })];
587
593
  case "tool_action_request":
588
594
  const displayCallId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
589
- const review = approvalReviewForCallId(approvalReviews, event.callId);
595
+ const rawReview = approvalReviewForCallId(approvalReviews, event.callId);
596
+ const review = rawReview && location ? compactCapabilityReview(rawReview, { ...location, callId: displayCallId }) : rawReview;
590
597
  const target = event.kind === "custom_approval" ? approvalTargets.get(event.callId) : undefined;
591
598
  const allowAlways = target
592
599
  ? target.always
@@ -919,9 +926,9 @@ export class AiTurnExecutor {
919
926
  const helpEnabled = toolActor !== null;
920
927
  const toolDiscoveryEnabled = toolActor !== null;
921
928
  const appToolsEnabled = toolActor !== null && defaultToolSource?.appTools === true;
922
- let capabilityAuthority: Awaited<ReturnType<typeof resolveAiCapabilityActor>> | null = null;
929
+ let toolAuthority: Awaited<ReturnType<typeof resolveAiCapabilityActor>> | null = null;
923
930
  try {
924
- capabilityAuthority = appToolsEnabled
931
+ toolAuthority = toolActor
925
932
  ? await resolveAiCapabilityActor({ conversationId, persistedActor: material.actor, store: aiConversations })
926
933
  : null;
927
934
  } catch (error) {
@@ -940,7 +947,10 @@ export class AiTurnExecutor {
940
947
  );
941
948
  return;
942
949
  }
943
- if (capabilityAuthority) material.actor = capabilityAuthority.actor;
950
+ if (toolAuthority) material.actor = toolAuthority.actor;
951
+ // Skills and Help need current authority even when application operations are disabled.
952
+ // Keep the appTools switch responsible for exposing and executing those operations.
953
+ const capabilityAuthority = appToolsEnabled ? toolAuthority : null;
944
954
 
945
955
  // Personalization applies to user-backed personal conversations.
946
956
  const user = aiActorUser(material.actor);
@@ -959,7 +969,9 @@ export class AiTurnExecutor {
959
969
  // executions must not gain an implicit database dependency or extra tools.
960
970
  const skillSubject =
961
971
  defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
962
- const availableSkills = skillSubject ? (await aiSkills.list(skillSubject)).filter((skill) => skill.enabled) : [];
972
+ const availableSkills = skillSubject
973
+ ? (await aiSkills.list(skillSubject, { authority: toolAuthority })).filter((skill) => skill.enabled)
974
+ : [];
963
975
  const localClient = !config.background && !config.mandate ? config.client : undefined;
964
976
  const localContext = localClient ? config.localContext : undefined;
965
977
  const localSkills = (localContext?.skills ?? []).map((skill) => ({
@@ -996,15 +1008,15 @@ export class AiTurnExecutor {
996
1008
  : undefined;
997
1009
  const skillFiles = skillSubject
998
1010
  ? {
999
- list: () => aiSkills.listTurnFiles(turnId, skillSubject),
1000
- read: (path: string) => aiSkills.readTurnFile(turnId, path, skillSubject),
1011
+ list: () => aiSkills.listTurnFiles(turnId, skillSubject, toolAuthority),
1012
+ read: (path: string) => aiSkills.readTurnFile(turnId, path, skillSubject, toolAuthority),
1001
1013
  }
1002
1014
  : undefined;
1003
1015
  const runtimeTools = [
1004
1016
  ...material.tools,
1005
1017
  ...(memoryActive ? [createCloudAiMemoryTool(query)] : []),
1006
- ...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject)] : []),
1007
- ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills)] : []),
1018
+ ...(skillSubject && availableSkills.length ? [createCloudAiLoadSkillTool(skillSubject, toolAuthority)] : []),
1019
+ ...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(skillSubject, localSkills, toolAuthority)] : []),
1008
1020
  ...(config.project && resolvedProjectId && projectSubject
1009
1021
  ? [
1010
1022
  createCloudAiSearchProjectTool(resolvedProjectId, projectSubject),
@@ -1101,8 +1113,7 @@ export class AiTurnExecutor {
1101
1113
  describeReview: async (approvalCallId: string, review: CapabilityActionReviewData) => {
1102
1114
  capabilityActionReviews.set(approvalCallId, review);
1103
1115
  const callId = customApprovalParentCallId(approvalCallId) ?? approvalCallId;
1104
- const stored = storedActionReview(review);
1105
- await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1116
+ const stored = await recordAiToolReview({ conversationId, turnId, callId, review: storedActionReview(review) });
1106
1117
  storedReviews.set(callId, stored);
1107
1118
  await pipeline.setToolReview(callId, stored);
1108
1119
  },
@@ -1125,8 +1136,12 @@ export class AiTurnExecutor {
1125
1136
  const scope = capabilityReviewRememberScope(review, approval);
1126
1137
  if (scope) rememberableCapabilityApprovals.set(callId, scope);
1127
1138
  else rememberableCapabilityApprovals.delete(callId);
1128
- const stored = storedActionReview(review, capabilityActionPolicy(entry.app.manifest, entry.operation.localId));
1129
- await recordAiToolReview({ conversationId, turnId, callId, review: stored });
1139
+ const stored = await recordAiToolReview({
1140
+ conversationId,
1141
+ turnId,
1142
+ callId,
1143
+ review: storedActionReview(review, capabilityActionPolicy(entry.app.manifest, entry.operation.localId)),
1144
+ });
1130
1145
  storedReviews.set(callId, stored);
1131
1146
  await pipeline.setToolReview(callId, stored);
1132
1147
  };
@@ -1253,7 +1268,8 @@ export class AiTurnExecutor {
1253
1268
  const tools = toolActor
1254
1269
  ? createAiToolResolver({
1255
1270
  conversationId,
1256
- actor: capabilityAuthority?.actor ?? toolActor,
1271
+ actor: toolAuthority?.actor ?? toolActor,
1272
+ authority: toolAuthority,
1257
1273
  staticTools: activeTools,
1258
1274
  ...(activeTools.some((tool) => tool.def.name === "load_skill") ? { skillNames: availableSkills.map((skill) => skill.name) } : {}),
1259
1275
  allowedTools,
@@ -1472,6 +1488,7 @@ export class AiTurnExecutor {
1472
1488
  turnId,
1473
1489
  skillSubject,
1474
1490
  activeTools.some((tool) => tool.def.name === "load_skill"),
1491
+ toolAuthority,
1475
1492
  );
1476
1493
  const chat = await aiConversations.getConversation({ conversationId });
1477
1494
  const workingPlan = chat?.todoPlan;
@@ -1742,7 +1759,7 @@ export class AiTurnExecutor {
1742
1759
  approvalContext?: AiToolApprovalContext;
1743
1760
  allowRememberedApprovals: boolean;
1744
1761
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1745
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1762
+ capabilityActionReviews: Map<string, CapabilityActionReviewData>;
1746
1763
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1747
1764
  linkedApprovals: ReadonlyMap<string, string>;
1748
1765
  freshApprovalRequired?: ReadonlySet<string>;
@@ -1927,7 +1944,7 @@ export class AiTurnExecutor {
1927
1944
  approvalContext?: AiToolApprovalContext;
1928
1945
  allowRememberedApprovals: boolean;
1929
1946
  rememberableCapabilityApprovals: ReadonlyMap<string, string>;
1930
- capabilityActionReviews: ReadonlyMap<string, CapabilityActionReviewData>;
1947
+ capabilityActionReviews: Map<string, CapabilityActionReviewData>;
1931
1948
  approvalTargets: ReadonlyMap<string, AiApprovalTarget>;
1932
1949
  linkedApprovals: ReadonlyMap<string, string>;
1933
1950
  freshApprovalRequired?: ReadonlySet<string>;
@@ -2016,7 +2033,7 @@ export class AiTurnExecutor {
2016
2033
  }
2017
2034
  }
2018
2035
 
2019
- const review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
2036
+ let review = event.kind === "custom_approval" ? approvalReviewForCallId(capabilityActionReviews, event.callId) : undefined;
2020
2037
  let approvalId: string | undefined;
2021
2038
  let presented = event;
2022
2039
  if (event.kind !== "client_tool") {
@@ -2031,6 +2048,24 @@ export class AiTurnExecutor {
2031
2048
  approvalId = linked;
2032
2049
  // The card shows the question the effect's own record holds, never the text the relay sent.
2033
2050
  presented = { ...event, message: record.sentence };
2051
+ // A later linked question without structured details replaces an earlier child's review too.
2052
+ review = record.details === null ? undefined : CapabilityActionReviewDataSchema.parse(record.details);
2053
+ if (review) capabilityActionReviews.set(event.callId, review);
2054
+ else capabilityActionReviews.delete(event.callId);
2055
+ const callId = event.kind === "custom_approval" ? (customApprovalParentCallId(event.callId) ?? event.callId) : event.callId;
2056
+ const capability = review && "sentence" in review && !review.ask ? await getCapability(record.appId).catch(() => null) : null;
2057
+ // Keep the original proposal on the existing tool-call message as well as the approval.
2058
+ // Its chat reference must remain readable after the approval has been consumed.
2059
+ const stored = await recordAiToolReview({
2060
+ conversationId,
2061
+ turnId,
2062
+ callId,
2063
+ review: storedActionReview(
2064
+ review ?? { message: record.sentence },
2065
+ capability ? capabilityActionPolicy(capability.manifest, record.action) : undefined,
2066
+ ),
2067
+ });
2068
+ await pipeline.setToolReview(callId, stored);
2034
2069
  } else {
2035
2070
  const record = await approvals.request({
2036
2071
  ...approvalScopeFromToolName(toolName),
@@ -2315,7 +2350,7 @@ class StreamPipeline {
2315
2350
  this.seq = input.startSeq;
2316
2351
  this.blocks = [];
2317
2352
  this.allowRememberedApprovals = input.allowRememberedApprovals;
2318
- this.mapper = createEventMapper(input.attempt, input.seedBlocks, input.allowRememberedApprovals);
2353
+ this.mapper = createEventMapper(input.attempt, input.seedBlocks, input.allowRememberedApprovals, input);
2319
2354
  }
2320
2355
 
2321
2356
  private ordered(run: () => Promise<void>): Promise<void> {
@@ -3,6 +3,7 @@ import { type RequestOptions as HttpsRequestOptions, request as httpsRequest } f
3
3
  import { z } from "zod";
4
4
  import { DOCUMENT_EXTRACTION_MAX_INPUT_BYTES, DOCUMENT_EXTRACTION_MAX_OUTPUT_BYTES } from "../services/document-extraction";
5
5
  import { type PublicNetworkAddress, resolvePublicNetworkAddresses } from "../services/network-security";
6
+ import { awaitNetworkOperation, readNetworkResponse, withNetworkDeadline } from "../services/network-transport";
6
7
  import { AI_FILES_MAX_FILE_BYTES_DEFAULT, aiFileStore, guessAiMediaType, normalizeAiFilePath } from "./files-store";
7
8
  import { authorizeWebRead } from "./firecrawl-tools";
8
9
  import { htmlToReadableMarkdown } from "./readable-html";
@@ -21,10 +22,11 @@ type PublicFileResponse = {
21
22
  type FetchFileDependencies = {
22
23
  resolve?: (hostname: string) => Promise<PublicNetworkAddress[]>;
23
24
  request?: RequestFactory;
25
+ timeoutMs?: number;
24
26
  requestPublicFile?: (
25
27
  url: URL,
26
28
  addresses: PublicNetworkAddress[],
27
- options: { maxBytes: number; signal?: AbortSignal },
29
+ options: { maxBytes: number; signal: AbortSignal },
28
30
  ) => Promise<PublicFileResponse>;
29
31
  };
30
32
 
@@ -72,7 +74,6 @@ export const buildPinnedFetchFileRequestOptions = (url: URL, addresses: PublicNe
72
74
  },
73
75
  servername: url.hostname,
74
76
  rejectUnauthorized: true,
75
- timeout: FETCH_FILE_REQUEST_TIMEOUT_MS,
76
77
  }));
77
78
  };
78
79
 
@@ -103,52 +104,40 @@ const httpFileError = (statusCode: number): Error => {
103
104
  const requestOneAddress = (
104
105
  request: RequestFactory,
105
106
  options: HttpsRequestOptions,
106
- input: { maxBytes: number; signal?: AbortSignal },
107
- ): Promise<PublicFileResponse> =>
108
- new Promise((resolve, reject) => {
109
- let settled = false;
110
- let responseReceived = false;
111
- let outgoing: ClientRequest;
112
- const abort = () => outgoing.destroy(new Error("File download was cancelled."));
113
- const finish = (result: { value?: PublicFileResponse; error?: Error }) => {
114
- if (settled) return;
115
- settled = true;
116
- input.signal?.removeEventListener("abort", abort);
117
- if (result.error) reject(responseReceived ? Object.assign(result.error, { responseReceived: true }) : result.error);
118
- else resolve(result.value!);
119
- };
120
- outgoing = request(options, (incoming) => {
107
+ input: { maxBytes: number; signal: AbortSignal },
108
+ ): Promise<PublicFileResponse> => {
109
+ let responseReceived = false;
110
+ return readNetworkResponse<PublicFileResponse>({
111
+ request,
112
+ options,
113
+ signal: input.signal,
114
+ read: (incoming, resolve, reject) => {
121
115
  responseReceived = true;
122
116
  const statusCode = incoming.statusCode ?? 0;
123
117
  if (statusCode >= 300 && statusCode < 400) {
124
- incoming.resume();
125
- finish({ value: { statusCode, headers: incoming.headers, bytes: new Uint8Array() } });
118
+ resolve({ statusCode, headers: incoming.headers, bytes: new Uint8Array() });
126
119
  return;
127
120
  }
128
121
  if (statusCode < 200 || statusCode >= 300) {
129
- incoming.resume();
130
- finish({ error: httpFileError(statusCode) });
122
+ reject(httpFileError(statusCode));
131
123
  return;
132
124
  }
133
125
  const contentEncoding = String(incoming.headers["content-encoding"] ?? "identity")
134
126
  .trim()
135
127
  .toLowerCase();
136
128
  if (contentEncoding !== "" && contentEncoding !== "identity") {
137
- incoming.destroy();
138
- finish({ error: responseError("Unsupported file response — The server compressed the download unexpectedly.") });
129
+ reject(responseError("Unsupported file response — The server compressed the download unexpectedly."));
139
130
  return;
140
131
  }
141
132
  let declared: number | null;
142
133
  try {
143
134
  declared = declaredContentLength(incoming.headers);
144
135
  } catch (error) {
145
- incoming.destroy();
146
- finish({ error: responseError(error instanceof Error ? error.message : "File response length is invalid.") });
136
+ reject(responseError(error instanceof Error ? error.message : "File response length is invalid."));
147
137
  return;
148
138
  }
149
139
  if (declared !== null && declared > input.maxBytes) {
150
- incoming.destroy();
151
- finish({ error: responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`) });
140
+ reject(responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`));
152
141
  return;
153
142
  }
154
143
 
@@ -158,36 +147,29 @@ const requestOneAddress = (
158
147
  const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
159
148
  size += bytes.byteLength;
160
149
  if (size > input.maxBytes) {
161
- incoming.destroy();
162
- finish({ error: responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`) });
150
+ reject(responseError(`File is too large — The download exceeds the ${fileLimitLabel(input.maxBytes)} limit.`));
163
151
  return;
164
152
  }
165
153
  chunks.push(bytes);
166
154
  });
167
155
  incoming.on("end", () => {
168
156
  if (declared !== null && size !== declared) {
169
- finish({ error: responseError("Incomplete file download — The received size did not match the server response.") });
157
+ reject(responseError("Incomplete file download — The received size did not match the server response."));
170
158
  return;
171
159
  }
172
160
  const body = Buffer.concat(chunks, size);
173
- finish({ value: { statusCode, headers: incoming.headers, bytes: new Uint8Array(body) } });
161
+ resolve({ statusCode, headers: incoming.headers, bytes: new Uint8Array(body) });
174
162
  });
175
- incoming.on("error", (error) => finish({ error }));
176
- });
177
- outgoing.on("timeout", () => outgoing.destroy(new Error("File server did not respond — Try again later.")));
178
- outgoing.on("error", (error) => finish({ error }));
179
- if (input.signal?.aborted) {
180
- abort();
181
- return;
182
- }
183
- input.signal?.addEventListener("abort", abort, { once: true });
184
- outgoing.end();
163
+ },
164
+ }).catch((error) => {
165
+ throw responseReceived ? Object.assign(error, { responseReceived: true }) : error;
185
166
  });
167
+ };
186
168
 
187
169
  const requestPublicFile = async (
188
170
  url: URL,
189
171
  addresses: PublicNetworkAddress[],
190
- options: { maxBytes: number; signal?: AbortSignal },
172
+ options: { maxBytes: number; signal: AbortSignal },
191
173
  request: RequestFactory = httpsRequest,
192
174
  ): Promise<PublicFileResponse> => {
193
175
  let lastError: unknown;
@@ -235,42 +217,51 @@ type WebsiteApprovedDownload = { origin: string; onRequest: (url: URL, hop: numb
235
217
  export const downloadPublicFile = async (
236
218
  rawUrl: string,
237
219
  options: FetchFileDependencies & { maxBytes?: number; signal?: AbortSignal; approvedWebsite?: WebsiteApprovedDownload } = {},
238
- ): Promise<{ bytes: Uint8Array; headers: IncomingHttpHeaders; url: URL }> => {
239
- const resolve = options.resolve ?? resolvePublicNetworkAddresses;
240
- const perform =
241
- options.requestPublicFile ??
242
- ((url: URL, addresses: PublicNetworkAddress[], input: { maxBytes: number; signal?: AbortSignal }) =>
243
- requestPublicFile(url, addresses, input, options.request));
244
- const maxBytes = options.maxBytes ?? AI_FILES_MAX_FILE_BYTES_DEFAULT;
245
- let url = publicHttpsUrl(rawUrl);
220
+ ): Promise<{ bytes: Uint8Array; headers: IncomingHttpHeaders; url: URL }> =>
221
+ withNetworkDeadline(
222
+ options.timeoutMs ?? FETCH_FILE_REQUEST_TIMEOUT_MS,
223
+ options.signal,
224
+ new Error("File server did not respond — Try again later."),
225
+ async (signal) => {
226
+ const resolve = options.resolve ?? resolvePublicNetworkAddresses;
227
+ const perform =
228
+ options.requestPublicFile ??
229
+ ((url: URL, addresses: PublicNetworkAddress[], input: { maxBytes: number; signal: AbortSignal }) =>
230
+ requestPublicFile(url, addresses, input, options.request));
231
+ const maxBytes = options.maxBytes ?? AI_FILES_MAX_FILE_BYTES_DEFAULT;
232
+ let url = publicHttpsUrl(rawUrl);
246
233
 
247
- for (let redirects = 0; redirects <= FETCH_FILE_MAX_REDIRECTS; redirects++) {
248
- if (options.approvedWebsite) {
249
- // A website approval covers its own origin only; an open redirect must not carry the address to another one.
250
- if (url.origin !== options.approvedWebsite.origin)
251
- throw new Error(
252
- `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.`,
253
- );
254
- await options.approvedWebsite.onRequest(url, redirects);
255
- }
256
- const addresses = await resolveFileAddresses(resolve, url.hostname);
257
- const response = await perform(url, addresses, { maxBytes, signal: options.signal });
258
- if (response.statusCode >= 300 && response.statusCode < 400) {
259
- const location = responseLocation(response.headers);
260
- if (!location) throw new Error("Invalid file redirect — The server did not provide a destination.");
261
- if (redirects === FETCH_FILE_MAX_REDIRECTS) throw new Error("Too many file redirects — Use the final file URL instead.");
262
- try {
263
- url = publicHttpsUrl(new URL(location, url).toString());
264
- } catch (error) {
265
- if (error instanceof Error && error.message.includes(" — ")) throw error;
266
- throw new Error("Invalid file redirect — The server provided an invalid destination.");
234
+ for (let redirects = 0; redirects <= FETCH_FILE_MAX_REDIRECTS; redirects++) {
235
+ signal.throwIfAborted();
236
+ if (options.approvedWebsite) {
237
+ // A website approval covers its own origin only; an open redirect must not carry the address to another one.
238
+ if (url.origin !== options.approvedWebsite.origin)
239
+ throw new Error(
240
+ `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.`,
241
+ );
242
+ await awaitNetworkOperation(options.approvedWebsite.onRequest(url, redirects), signal);
243
+ }
244
+ signal.throwIfAborted();
245
+ const addresses = await awaitNetworkOperation(resolveFileAddresses(resolve, url.hostname), signal);
246
+ signal.throwIfAborted();
247
+ const response = await awaitNetworkOperation(perform(url, addresses, { maxBytes, signal }), signal);
248
+ if (response.statusCode >= 300 && response.statusCode < 400) {
249
+ const location = responseLocation(response.headers);
250
+ if (!location) throw new Error("Invalid file redirect — The server did not provide a destination.");
251
+ if (redirects === FETCH_FILE_MAX_REDIRECTS) throw new Error("Too many file redirects — Use the final file URL instead.");
252
+ try {
253
+ url = publicHttpsUrl(new URL(location, url).toString());
254
+ } catch (error) {
255
+ if (error instanceof Error && error.message.includes(" — ")) throw error;
256
+ throw new Error("Invalid file redirect — The server provided an invalid destination.");
257
+ }
258
+ continue;
259
+ }
260
+ return { bytes: response.bytes, headers: response.headers, url };
267
261
  }
268
- continue;
269
- }
270
- return { bytes: response.bytes, headers: response.headers, url };
271
- }
272
- throw new Error("Too many file redirects — Use the final file URL instead.");
273
- };
262
+ throw new Error("Too many file redirects — Use the final file URL instead.");
263
+ },
264
+ );
274
265
 
275
266
  const safeFilename = (requested: string | undefined, url: URL): string => {
276
267
  let basename = requested;
package/src/ai/http.ts CHANGED
@@ -70,6 +70,7 @@ export const AiToolPreloadSchema = z.union([
70
70
  ]);
71
71
 
72
72
  export const AiCreateConversationInputSchema = z.object({
73
+ requestId: z.uuid().optional(),
73
74
  title: z.string().trim().min(1).max(120).optional(),
74
75
  projectId: z.string().regex(AI_SHORT_ID_PATTERN).optional(),
75
76
  draft: AiInitialConversationDraftInputSchema.optional(),
package/src/ai/migrate.ts CHANGED
@@ -47,6 +47,17 @@ export const migrateCloudAi = async (): Promise<void> => {
47
47
  )
48
48
  `.simple();
49
49
 
50
+ // Keep the request identity after deletion: retrying a lost response must not resurrect a chat.
51
+ await sql`
52
+ CREATE TABLE IF NOT EXISTS ai.conversation_creations (
53
+ owner_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
54
+ request_id UUID NOT NULL,
55
+ input_hash TEXT NOT NULL,
56
+ conversation_id UUID REFERENCES ai.conversations(id) ON DELETE SET NULL,
57
+ PRIMARY KEY (owner_user_id, request_id)
58
+ )
59
+ `.simple();
60
+
50
61
  // AI chat ownership was unreleased and only backed local test data. Make the
51
62
  // product-model cut once, then keep only the global conversation contract.
52
63
  await sql`
@@ -2657,6 +2668,8 @@ export const migrateCloudAi = async (): Promise<void> => {
2657
2668
  );
2658
2669
  }
2659
2670
  await backfillApprovalRequests();
2671
+ await sql`CREATE INDEX IF NOT EXISTS idx_ai_messages_submitted_draft
2672
+ ON ai.messages (conversation_id, (meta->>'submittedDraftRevision')) WHERE role = 'user'`.simple();
2660
2673
  console.log(" ✓ ai conversation tables");
2661
2674
  };
2662
2675
 
@@ -1,12 +1,8 @@
1
1
  import { sql } from "bun";
2
2
  import { z } from "zod";
3
3
  import { APPROVALS_PAGE_SIZE } from "../contracts/approvals";
4
- import {
5
- CapabilityActionCallReviewSchema,
6
- CapabilityActionReviewDataSchema,
7
- CapabilitySemanticLinkSchema,
8
- } from "../contracts/capabilities";
9
- import { openRequests } from "../services/approvals/list";
4
+ import { CapabilityActionReviewPresentationSchema, CapabilitySemanticLinkSchema } from "../contracts/capabilities";
5
+ import { openRequests } from "../services/approvals/content";
10
6
  import { logger } from "../services/logging";
11
7
  import { reviewShortText, toolActionCard, toolBlockReview } from "./chat/action-card";
12
8
  import { aiToolIcon } from "./chat/message-utils";
@@ -142,11 +138,11 @@ export const openQuestionFromRow = (row: QuestionRow, input: { locale: string; t
142
138
  !["running", "awaiting_approval", "awaiting_client", "completed", "failed", "rejected"].includes(block.status)
143
139
  )
144
140
  throw new Error("Malformed tool block");
145
- if (block.review == null && block.approval?.review != null) CapabilityActionReviewDataSchema.parse(block.approval.review);
141
+ if (block.review == null && block.approval?.review != null) CapabilityActionReviewPresentationSchema.parse(block.approval.review);
146
142
  const review = toolBlockReview(block);
147
143
  if (review !== null) {
148
144
  if ("legacy" in review) legacyReviewSchema.parse(review.legacy);
149
- else CapabilityActionCallReviewSchema.parse(review);
145
+ else CapabilityActionReviewPresentationSchema.parse(review);
150
146
  }
151
147
  const card = toolActionCard(block, {
152
148
  locale: input.locale,
@@ -295,8 +295,7 @@ const listProjectAccess = async (projectId: string, db: SQL = sql): Promise<AiPr
295
295
  const rows = await db<ProjectAccessRow[]>`
296
296
  SELECT project_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
297
297
  access.permission, access.created_at,
298
- COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
299
- CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
298
+ COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name) AS display_name,
300
299
  users.avatar_hash, service_accounts.kind AS service_account_kind
301
300
  FROM ai.project_access project_access
302
301
  JOIN auth.access access ON access.id = project_access.access_id
@@ -1,5 +1,5 @@
1
1
  import type { CompactResult, InboundEvent, Message } from "@k2b/nessi";
2
- import type { CapabilityActionReviewData } from "../contracts/capabilities";
2
+ import type { CapabilityActionReviewPresentation } from "../contracts/capabilities";
3
3
  import type { AiActionCheck, AiStoredActionReview } from "./action-review";
4
4
  import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresentation, AiTurnStatus } from "./types";
5
5
 
@@ -49,7 +49,7 @@ export type AiTurnBlock =
49
49
  /** Present while status is awaiting_approval. */
50
50
  approval?: {
51
51
  message?: string;
52
- review?: CapabilityActionReviewData;
52
+ review?: CapabilityActionReviewPresentation;
53
53
  allowAlways: boolean;
54
54
  /** Whether the approval can be remembered for this chat. */
55
55
  allowChat?: boolean;
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
3
- import { listCapabilities } from "../_internal/registry";
4
- import { type CapabilityDispatchParams, dispatchCapability, QUERY_TIMEOUT_MS } from "../api/capabilities";
3
+ import { CAPABILITY_DISCOVERY_TIMEOUT_MS } from "../_internal/capability-timeout";
4
+ import { type listApps, listCapabilities } from "../_internal/registry";
5
+ import { type CapabilityDispatchParams, dispatchCapability } from "../api/capabilities";
5
6
  import {
6
7
  CloudResourceRefSchema,
7
8
  capabilityResultSchema,
@@ -11,6 +12,7 @@ import {
11
12
  } from "../contracts/capabilities";
12
13
  import type { CapabilityRegistryEntry } from "../contracts/registry";
13
14
  import type { RequestAuthority } from "../server";
15
+ import { discoverableAppEntries } from "../services/app-discovery";
14
16
  import { resolveAppIdentityPresentation } from "../shared/app-presentation";
15
17
  import { LOCALE_HEADER } from "../shared/locale";
16
18
  import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
@@ -65,6 +67,7 @@ const studioReader = async (input: {
65
67
  export type AiRecentResourceDependencies = {
66
68
  store?: Pick<AiConversationService, "listRecentSentResourceRefs">;
67
69
  listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
70
+ listApps?: typeof listApps;
68
71
  dispatch?: (params: CapabilityDispatchParams) => Promise<Response>;
69
72
  skills?: Pick<typeof aiSkills, "list">;
70
73
  studioReader?: typeof studioReader;
@@ -108,7 +111,7 @@ export const listAiRecentResources = async (input: {
108
111
  dependencies?: AiRecentResourceDependencies;
109
112
  }): Promise<AiRecentResource[]> => {
110
113
  const deps = input.dependencies ?? {};
111
- const signal = AbortSignal.any([input.signal, AbortSignal.timeout(deps.timeoutMs ?? QUERY_TIMEOUT_MS)]);
114
+ const signal = AbortSignal.any([input.signal, AbortSignal.timeout(deps.timeoutMs ?? CAPABILITY_DISCOVERY_TIMEOUT_MS)]);
112
115
  // Unreadable history is no answer, not an empty one: the request fails, so the browser asks again at its next opening.
113
116
  const sent = await withinBudget(signal, () =>
114
117
  (deps.store ?? aiConversations).listRecentSentResourceRefs({ ownerUserId: input.ownerUserId }),
@@ -118,10 +121,14 @@ export const listAiRecentResources = async (input: {
118
121
  const skillIds = candidates.flatMap(({ ref }) => (ref.type === "core.ai.skill" ? [ref.id] : []));
119
122
  // Exactly the sent Skills, so a large Skill catalog never pushes one out; failing, they are left out like a reader.
120
123
  const skills = skillIds.length
121
- ? withinBudget(signal, () => (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds })).catch(() => [])
124
+ ? withinBudget(signal, () =>
125
+ (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds, authority: input.authority }),
126
+ ).catch(() => [])
122
127
  : Promise.resolve([]);
123
128
  const registry = candidates.some(({ ref }) => ref.type !== "core.ai.skill" && ref.type !== "assistant.artifact")
124
- ? withinBudget(signal, deps.listRegistry ?? listCapabilities).catch(() => [])
129
+ ? withinBudget(signal, async () =>
130
+ discoverableAppEntries(input.authority, await (deps.listRegistry ?? listCapabilities)(), deps.listApps),
131
+ ).catch(() => [])
125
132
  : Promise.resolve([]);
126
133
  const resources = await Promise.all(
127
134
  candidates.map(async (candidate): Promise<AiRecentResource | null> => {