@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,4 +1,8 @@
1
- import { sql } from "bun";
1
+ import { type SQL, sql } from "bun";
2
+ import type { AccessSubject } from "../contracts/shared";
3
+ import type { RequestActor } from "../server/middleware/auth";
4
+ import { type AuditActor, type AuditRecordParams, audit } from "../services/audit";
5
+ import { toPgTextArray } from "../services/postgres";
2
6
  import type { AiToolApprovalPolicy, AiTurnRunConfig } from "./types";
3
7
 
4
8
  /** Background authority never inherits a user's interactive Always Allow preference. */
@@ -31,6 +35,16 @@ export type AiToolApprovalContext = {
31
35
  actorUserId: string;
32
36
  };
33
37
 
38
+ /**
39
+ * Request identity for approval changes; never persist this context in a turn run config.
40
+ * When actor is absent, audit uses { userId: actorUserId } for the approval's owner.
41
+ * The access subject is derived from actor, or falls back to { type: "user", userId: actorUserId }.
42
+ */
43
+ export type AiToolApprovalChangeContext = AiToolApprovalContext & {
44
+ actor?: RequestActor;
45
+ requestId?: string | null;
46
+ };
47
+
34
48
  /** Where a remembered approval applies: in one chat until it is deleted, or always. */
35
49
  export type AiToolApprovalRemember = "chat" | "always";
36
50
 
@@ -55,6 +69,59 @@ type AiToolApprovalPreferenceRow = {
55
69
  expires_at: Date | string | null;
56
70
  };
57
71
 
72
+ type ApprovalAuditRow = Pick<AiToolApprovalPreferenceRow, "id" | "tool_name" | "approval_scope" | "conversation_id">;
73
+ type ApprovalAuditIdentity = {
74
+ actor: AuditActor | null;
75
+ accessSubject: AccessSubject | null;
76
+ serviceAccountId?: string;
77
+ requestId?: string | null;
78
+ reason?: string;
79
+ };
80
+
81
+ const approvalAuditIdentity = (context: AiToolApprovalChangeContext): ApprovalAuditIdentity => {
82
+ const user = context.actor?.kind === "user" ? context.actor.user : context.actor?.delegatedUser;
83
+ return {
84
+ actor: context.actor
85
+ ? user
86
+ ? { userId: user.id, uid: user.uid, provider: user.provider, roles: user.roles }
87
+ : null
88
+ : { userId: context.actorUserId },
89
+ // Match aiChatAccessSubject locally: importing assistant-models creates a cycle through firecrawl-tools.
90
+ accessSubject: user
91
+ ? {
92
+ type: "user",
93
+ userId: user.id,
94
+ ...(context.actor?.kind === "service_account" ? { delegatedByServiceAccountId: context.actor.serviceAccount.id } : {}),
95
+ }
96
+ : context.actor?.kind === "service_account"
97
+ ? { type: "service_account", serviceAccountId: context.actor.serviceAccount.id }
98
+ : { type: "user", userId: context.actorUserId },
99
+ ...(context.actor?.kind === "service_account" ? { serviceAccountId: context.actor.serviceAccount.id } : {}),
100
+ requestId: context.requestId,
101
+ };
102
+ };
103
+
104
+ const approvalAuditEvent = (
105
+ action: "ai.approval.remember" | "ai.approval.revoke",
106
+ row: ApprovalAuditRow,
107
+ identity: ApprovalAuditIdentity,
108
+ ): AuditRecordParams => ({
109
+ action,
110
+ outcome: "allowed",
111
+ actor: identity.actor,
112
+ target: { type: "ai_approval", id: row.id, label: row.tool_name },
113
+ requestId: identity.requestId,
114
+ reason: identity.reason,
115
+ metadata: {
116
+ toolName: row.tool_name,
117
+ approvalScope: row.approval_scope,
118
+ remember: row.conversation_id !== null ? "chat" : "always",
119
+ ...(row.conversation_id !== null ? { conversationId: row.conversation_id } : {}),
120
+ accessSubject: identity.accessSubject,
121
+ ...(identity.serviceAccountId ? { serviceAccountId: identity.serviceAccountId } : {}),
122
+ },
123
+ });
124
+
58
125
  export const aiToolApprovalScope = (toolName: string, policy: AiToolApprovalPolicy | undefined): string => {
59
126
  if (policy && typeof policy === "object") return policy.scope ?? toolName;
60
127
  return toolName;
@@ -100,20 +167,12 @@ export const hasRememberedAiToolApproval = async (
100
167
 
101
168
  /** Remembers an approval everywhere, or in one chat when `conversationId` is set; a chat approval ends with its chat. */
102
169
  export const rememberAiToolApproval = async (
103
- context: AiToolApprovalContext,
170
+ context: AiToolApprovalChangeContext,
104
171
  input: { toolName: string; approvalScope: string; conversationId?: string | null; expiresAt?: Date | null },
105
172
  ): Promise<void> => {
106
173
  const conversationId = input.conversationId ?? null;
107
- await sql.begin(async () => {
108
- await sql`
109
- DELETE FROM ai.tool_approval_preferences
110
- WHERE actor_user_id = ${context.actorUserId}
111
- AND tool_name = ${input.toolName}
112
- AND approval_scope = ${input.approvalScope}
113
- AND conversation_id IS NOT DISTINCT FROM ${conversationId}::uuid
114
- `;
115
-
116
- await sql`
174
+ await sql.begin(async (tx) => {
175
+ const [row] = await tx<{ id: string }[]>`
117
176
  INSERT INTO ai.tool_approval_preferences (
118
177
  actor_user_id,
119
178
  tool_name,
@@ -130,24 +189,46 @@ export const rememberAiToolApproval = async (
130
189
  now(),
131
190
  ${input.expiresAt ?? null}
132
191
  )
192
+ ON CONFLICT (actor_user_id, tool_name, approval_scope, conversation_id)
193
+ DO UPDATE SET
194
+ created_at = EXCLUDED.created_at,
195
+ last_used_at = EXCLUDED.last_used_at,
196
+ expires_at = EXCLUDED.expires_at
197
+ RETURNING id
133
198
  `;
199
+ await audit.record(
200
+ approvalAuditEvent(
201
+ "ai.approval.remember",
202
+ {
203
+ id: row!.id,
204
+ tool_name: input.toolName,
205
+ approval_scope: input.approvalScope,
206
+ conversation_id: conversationId,
207
+ },
208
+ approvalAuditIdentity(context),
209
+ ),
210
+ tx,
211
+ );
134
212
  });
135
213
  };
136
214
 
137
215
  /** Forgets the approval with exactly this reach: everywhere, or in the one chat `conversationId` names. */
138
216
  export const forgetAiToolApproval = async (
139
- context: AiToolApprovalContext,
217
+ context: AiToolApprovalChangeContext,
140
218
  input: { toolName: string; approvalScope: string; conversationId?: string | null },
141
219
  ): Promise<boolean> => {
142
- const rows = await sql<{ id: string }[]>`
143
- DELETE FROM ai.tool_approval_preferences
144
- WHERE actor_user_id = ${context.actorUserId}
145
- AND tool_name = ${input.toolName}
146
- AND approval_scope = ${input.approvalScope}
147
- AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
148
- RETURNING id
149
- `;
150
- return rows.length > 0;
220
+ return sql.begin(async (tx) => {
221
+ const rows = await tx<ApprovalAuditRow[]>`
222
+ DELETE FROM ai.tool_approval_preferences
223
+ WHERE actor_user_id = ${context.actorUserId}
224
+ AND tool_name = ${input.toolName}
225
+ AND approval_scope = ${input.approvalScope}
226
+ AND conversation_id IS NOT DISTINCT FROM ${input.conversationId ?? null}::uuid
227
+ RETURNING id, tool_name, approval_scope, conversation_id
228
+ `;
229
+ for (const row of rows) await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity(context)), tx);
230
+ return rows.length > 0;
231
+ });
151
232
  };
152
233
 
153
234
  const toIsoString = (value: Date | string): string => (value instanceof Date ? value : new Date(value)).toISOString();
@@ -186,12 +267,42 @@ export const listAiToolApprovalPreferences = async (
186
267
  return rows.map(approvalPreferenceFromRow);
187
268
  };
188
269
 
189
- export const revokeAiToolApprovalPreference = async (actorUserId: string, preferenceId: string): Promise<boolean> => {
190
- const rows = await sql<{ id: string }[]>`
270
+ export const revokeAiToolApprovalPreference = async (
271
+ actorUserId: string,
272
+ preferenceId: string,
273
+ change?: { actor?: RequestActor; requestId?: string | null },
274
+ ): Promise<boolean> =>
275
+ sql.begin(async (tx) => {
276
+ const rows = await tx<ApprovalAuditRow[]>`
277
+ DELETE FROM ai.tool_approval_preferences
278
+ WHERE id = ${preferenceId}
279
+ AND actor_user_id = ${actorUserId}
280
+ RETURNING id, tool_name, approval_scope, conversation_id
281
+ `;
282
+ for (const row of rows)
283
+ await audit.record(approvalAuditEvent("ai.approval.revoke", row, approvalAuditIdentity({ ...change, actorUserId })), tx);
284
+ return rows.length > 0;
285
+ });
286
+
287
+ /** Core migrations supply their transaction so deletion and system audit events commit together. */
288
+ export const revokeAiToolApprovalPreferencesAsSystem = async (
289
+ db: SQL,
290
+ input: { ids: readonly string[]; reason: string },
291
+ ): Promise<number> => {
292
+ if (input.ids.length === 0) return 0;
293
+ const rows = await db<(ApprovalAuditRow & { actor_user_id: string | null })[]>`
191
294
  DELETE FROM ai.tool_approval_preferences
192
- WHERE id = ${preferenceId}
193
- AND actor_user_id = ${actorUserId}
194
- RETURNING id
295
+ WHERE id = ANY(${toPgTextArray([...input.ids])}::uuid[])
296
+ RETURNING id, tool_name, approval_scope, conversation_id, actor_user_id
195
297
  `;
196
- return rows.length > 0;
298
+ for (const row of rows)
299
+ await audit.record(
300
+ approvalAuditEvent("ai.approval.revoke", row, {
301
+ actor: null,
302
+ accessSubject: row.actor_user_id === null ? null : { type: "user", userId: row.actor_user_id },
303
+ reason: input.reason,
304
+ }),
305
+ db,
306
+ );
307
+ return rows.length;
197
308
  };
@@ -0,0 +1,72 @@
1
+ import type { CapabilityActionPolicy, CapabilityActionReviewData } from "../contracts/capabilities";
2
+ import { aiChatTasks } from "./chat-tasks";
3
+
4
+ export class AiBackgroundApprovalRequiredError extends Error {
5
+ readonly code = "NEEDS_APPROVAL";
6
+
7
+ constructor(readonly action: string) {
8
+ super(
9
+ `NEEDS_APPROVAL: ${action} needs the user's approval every time, and routines never ask. Stop and say in one sentence what was missing.`,
10
+ );
11
+ this.name = "AiBackgroundApprovalRequiredError";
12
+ }
13
+ }
14
+
15
+ type BackgroundActionInput = {
16
+ turnId: string;
17
+ mandate: { id: string; revision: number };
18
+ appId: string;
19
+ capabilityId: string;
20
+ kind: "query" | "action";
21
+ args: unknown;
22
+ policy?: CapabilityActionPolicy;
23
+ approval?: "none" | "rememberable" | "always";
24
+ hasReview: boolean;
25
+ action: string;
26
+ review: () => Promise<CapabilityActionReviewData | null>;
27
+ signal?: AbortSignal;
28
+ };
29
+
30
+ /** Core routine gate. A review can narrow a mandate, never grant authority. */
31
+ export const authorizeBackgroundActionWith = async (
32
+ input: BackgroundActionInput,
33
+ authorizeCapability: typeof aiChatTasks.authorizeCapability,
34
+ ): Promise<CapabilityActionReviewData | null> => {
35
+ // These declarations can never be granted to a routine. Stop with the approval state even if a stale grant exists.
36
+ if (input.kind === "action" && input.policy && (!("silent" in input.policy) || input.policy.routines === false)) {
37
+ await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
38
+ throw new AiBackgroundApprovalRequiredError(input.action);
39
+ }
40
+ await authorizeCapability({
41
+ mandate: input.mandate,
42
+ appId: input.appId,
43
+ capabilityId: input.capabilityId,
44
+ kind: input.kind,
45
+ input: input.args,
46
+ approval: input.approval,
47
+ ...(input.policy ? { policy: input.policy } : {}),
48
+ });
49
+ if (input.kind === "query" || (!input.policy && input.approval === "none")) return null;
50
+ input.signal?.throwIfAborted();
51
+ if (!input.policy && input.approval !== "rememberable") {
52
+ await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
53
+ throw new AiBackgroundApprovalRequiredError(input.action);
54
+ }
55
+ let review: CapabilityActionReviewData | null = null;
56
+ if (input.hasReview && !input.signal?.aborted) {
57
+ try {
58
+ review = await input.review();
59
+ } catch {
60
+ // An unavailable review cannot authorize an unattended effect.
61
+ input.signal?.throwIfAborted();
62
+ }
63
+ }
64
+ input.signal?.throwIfAborted();
65
+ if (review && (input.policy ? "sentence" in review && review.ask === undefined : !("sentence" in review) && review.approvalScope))
66
+ return review;
67
+ await aiChatTasks.markNeedsApproval({ turnId: input.turnId, action: input.action });
68
+ throw new AiBackgroundApprovalRequiredError(input.action);
69
+ };
70
+
71
+ export const authorizeBackgroundAction = (input: BackgroundActionInput): Promise<CapabilityActionReviewData | null> =>
72
+ authorizeBackgroundActionWith(input, aiChatTasks.authorizeCapability);
package/src/ai/browser.ts CHANGED
@@ -189,6 +189,16 @@ export const launchAssistant = async (input: LaunchAssistantInput): Promise<Assi
189
189
  };
190
190
  };
191
191
 
192
+ export {
193
+ CHAT_TASK_REASON_CODES,
194
+ type ChatTaskReason,
195
+ type ChatTaskReasonCode,
196
+ chatTaskLocale,
197
+ chatTaskNeedsNewTime,
198
+ chatTaskReasonSentence,
199
+ chatTaskReasonState,
200
+ chatTaskStopSentence,
201
+ } from "./chat-task-reasons";
192
202
  export {
193
203
  CLOUD_AI_STATS_MAX_ITEMS,
194
204
  CLOUD_AI_TABLE_MAX_COLUMNS,
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { Tool, ToolContext, ToolResolver } from "@k2b/nessi";
3
3
  import { z } from "zod";
4
4
  import { resolveCapabilityActionWording, resolveCapabilityOperationTitle } from "../_internal/capabilities";
5
+ import { capabilityReviewView } from "../_internal/capability-reviews";
5
6
  import {
6
7
  CAPABILITY_APPROVAL_REASON_FIELD,
7
8
  CAPABILITY_APPROVAL_REASON_MAX_CHARS,
@@ -13,8 +14,9 @@ import {
13
14
  import { HELP_READ_MAX_CHARS, HELP_SEARCH_MAX_LIMIT, readHelpArticle } from "../_internal/help-catalog";
14
15
  import {
15
16
  type CapabilityActionManifest,
16
- type CapabilityActionReview,
17
+ type CapabilityActionReviewData,
17
18
  type CapabilityQueryManifest,
19
+ capabilityActionPolicy,
18
20
  cloudResourceRefAppId,
19
21
  LinkedCloudResourceReferenceSchema,
20
22
  resolveCapabilityResourceReader,
@@ -756,9 +758,14 @@ export const createLoadedAiCapabilityTools = (input: {
756
758
  actor: RequestActor;
757
759
  /** Locale of the turn; the approval text a person reads is worded in it. */
758
760
  locale?: string;
759
- review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReview | null>;
760
- authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
761
- onReview?: (callId: string, review: CapabilityActionReview) => void;
761
+ review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReviewData | null>;
762
+ authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<void>;
763
+ onReview?: (
764
+ callId: string,
765
+ review: CapabilityActionReviewData,
766
+ approval: CapabilityActionManifest["approval"],
767
+ entry: AiCapabilityCatalogEntry,
768
+ ) => void | Promise<void>;
762
769
  execute: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<unknown>;
763
770
  }): AiRuntimeTool[] => {
764
771
  const byName = new Map(input.catalog.map((entry) => [entry.name, entry]));
@@ -769,7 +776,7 @@ export const createLoadedAiCapabilityTools = (input: {
769
776
  defineAiTool({
770
777
  name: entry.providerName,
771
778
  canonicalName: entry.name,
772
- description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
779
+ description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN or NEEDS_APPROVAL. Never retry IDEMPOTENCY_UNCERTAIN or IDEMPOTENCY_IN_PROGRESS. After ACTION_NOT_DONE, ask the person before trying again. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
773
780
  inputSchema: aiCapabilityInputSchema(
774
781
  offersApprovalReason(entry) ? withApprovalReason(entry.operation.inputSchema) : entry.operation.inputSchema,
775
782
  ),
@@ -779,16 +786,21 @@ export const createLoadedAiCapabilityTools = (input: {
779
786
  approval: "never",
780
787
  }).server(async (modelArgs, context) => {
781
788
  const args = withoutApprovalReason(entry, modelArgs);
782
- await input.authorizeBackground?.(entry, args);
789
+ await input.authorizeBackground?.(entry, args, context);
783
790
  if (!input.authorizeBackground && entry.kind === "action" && (entry.operation as CapabilityActionManifest).approval !== "none") {
784
791
  const review = (await input.review?.(entry, args, context)) ?? null;
785
- if (review && context.callId) input.onReview?.(context.callId, review);
792
+ if (review && context.callId)
793
+ await input.onReview?.(context.callId, review, "approval" in entry.operation ? entry.operation.approval : undefined, entry);
786
794
  // Text-only readers such as the CLI read what the card shows: the app's sentence, its review, and the model's reason.
787
795
  const locale = input.locale ?? DEFAULT_LOCALE;
788
796
  const message = capabilityApprovalText({
789
797
  appName: entry.display.appName,
790
798
  subject: capabilityActionSubject(aiCapabilityActionWording(entry, locale), args, { locale, timeZone: context.timeZone }),
791
- review: review?.message,
799
+ review: review
800
+ ? capabilityReviewView(review, {
801
+ timeZone: context.timeZone ?? "UTC",
802
+ }).message
803
+ : undefined,
792
804
  reason: offersApprovalReason(entry) ? capabilityApprovalReason(modelArgs) : null,
793
805
  locale,
794
806
  });
@@ -873,9 +885,14 @@ export const createAiToolResolver =
873
885
  locale?: string;
874
886
  maxLoadedTools?: number;
875
887
  execute?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<unknown>;
876
- review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReview | null>;
877
- authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
878
- onReview?: (callId: string, review: CapabilityActionReview) => void;
888
+ review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReviewData | null>;
889
+ authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<void>;
890
+ onReview?: (
891
+ callId: string,
892
+ review: CapabilityActionReviewData,
893
+ approval: CapabilityActionManifest["approval"],
894
+ entry: AiCapabilityCatalogEntry,
895
+ ) => void | Promise<void>;
879
896
  onPrepared?: (snapshot: {
880
897
  prepared: PreparedAiTools;
881
898
  presentations: Map<string, AiToolPresentation>;
@@ -907,6 +924,7 @@ export const createAiToolResolver =
907
924
  capabilityId: entry.operation.localId,
908
925
  kind: entry.kind,
909
926
  approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
927
+ capabilityPolicy: capabilityActionPolicy(entry.app.manifest, entry.operation.localId),
910
928
  }));
911
929
  const mandateExcluded = new Set(
912
930
  fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
@@ -1,8 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolContext } from "@k2b/nessi";
3
+ import { capabilityActionReceiptIssues, capabilityQueryChecksActionOutcome } from "../_internal/capability-reviews";
3
4
  import { type CapabilityDispatchDependencies, dispatchCapability } from "../api/capabilities";
4
5
  import { capabilityValueMeta, recordCapabilityExecution } from "../capabilities/executions";
5
- import { type CapabilityActionManifest, type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
6
+ import {
7
+ type CapabilityActionCallReview,
8
+ CapabilityActionCheckSchema,
9
+ type CapabilityActionManifest,
10
+ type CapabilityActionReviewData,
11
+ CapabilityActionReviewDataSchema,
12
+ } from "../contracts/capabilities";
6
13
  import type { RequestActor } from "../server";
7
14
  import type { AccessSubject } from "../server/services/access";
8
15
  import { isAccountCategoryAllowed } from "../services/account-category-policy";
@@ -10,10 +17,14 @@ import { isAccountExpired } from "../services/account-model";
10
17
  import { accounts } from "../services/accounts";
11
18
  import { withoutAdminRole } from "../services/session/user";
12
19
  import { LOCALE_HEADER } from "../shared/locale";
20
+ import type { AiActionCheck } from "./action-review";
13
21
  import type { AiCapabilityCatalogEntry } from "./capabilities";
22
+ import { AiCapabilityExecutionError } from "./capability-result";
14
23
  import { aiToolAudit } from "./tool-audit";
15
24
  import type { AiConversationService } from "./types";
16
25
 
26
+ export { AiCapabilityExecutionError, aiCapabilityCheckResult } from "./capability-result";
27
+
17
28
  type CapabilityActor = Extract<RequestActor, { kind: "user" }>;
18
29
 
19
30
  export const resolveAiCapabilityActor = async (input: {
@@ -38,17 +49,6 @@ export const resolveAiCapabilityActor = async (input: {
38
49
  return { actor: { kind: "user", user }, accessSubject: { type: "user", userId: user.id } };
39
50
  };
40
51
 
41
- export class AiCapabilityExecutionError extends Error {
42
- constructor(
43
- readonly code: string,
44
- readonly status: number,
45
- message: string,
46
- ) {
47
- super(`${code}: ${message}`);
48
- this.name = "AiCapabilityExecutionError";
49
- }
50
- }
51
-
52
52
  const idempotencyKey = (conversationId: string, callId: string): string =>
53
53
  `ai-${createHash("sha256").update(`${conversationId}\0${callId}`).digest("hex")}`;
54
54
 
@@ -71,13 +71,13 @@ const validationIssueText = (details: unknown): string => {
71
71
  return issues.length > 0 ? ` Input issues: ${issues.join("; ")}` : "";
72
72
  };
73
73
 
74
- const parseCapabilityResponse = async (response: Response): Promise<unknown> => {
74
+ const parseCapabilityResponse = async (response: Response, dispatched: boolean): Promise<unknown> => {
75
75
  const body = (await response.json().catch(() => null)) as { code?: unknown; message?: unknown; details?: unknown } | null;
76
76
  if (response.ok) return body;
77
77
  const code = typeof body?.code === "string" ? body.code : "CAPABILITY_FAILED";
78
78
  const message = typeof body?.message === "string" ? body.message : "Capability execution failed";
79
79
  const issueText = code === "VALIDATION_FAILED" ? validationIssueText(body?.details) : "";
80
- throw new AiCapabilityExecutionError(code, response.status, `${message}${issueText}`);
80
+ throw new AiCapabilityExecutionError(code, response.status, `${message}${issueText}`, dispatched);
81
81
  };
82
82
 
83
83
  type AiCapabilityCall = {
@@ -96,7 +96,10 @@ type AiCapabilityCall = {
96
96
  };
97
97
  };
98
98
 
99
- const dispatchAiCapability = async (input: AiCapabilityCall, review: boolean): Promise<unknown> => {
99
+ const dispatchAiCapability = async (
100
+ input: Omit<AiCapabilityCall, "context"> & { context: Pick<ToolContext, "callId" | "signal"> },
101
+ review: boolean,
102
+ ): Promise<unknown> => {
100
103
  if (input.authority.accessSubject.type !== "user" || input.authority.accessSubject.userId !== input.authority.actor.user.id) {
101
104
  throw new Error("Cloud capability authority is inconsistent.");
102
105
  }
@@ -128,41 +131,71 @@ const dispatchAiCapability = async (input: AiCapabilityCall, review: boolean): P
128
131
  headers,
129
132
  signal: input.context.signal,
130
133
  });
131
- return await parseCapabilityResponse(
132
- await dispatch({
133
- request,
134
- kind: input.entry.kind === "query" ? "queries" : "actions",
135
- origin: "assistant",
136
- review,
137
- appId: input.entry.appId,
138
- capabilityId: input.entry.operation.localId,
139
- input: input.args,
140
- authority: {
141
- actor: input.authority.actor,
142
- accessSubject: input.authority.accessSubject,
143
- credentialKind: "session",
144
- scopes: [],
145
- },
146
- mandate: input.mandate
147
- ? {
148
- mandateId: input.mandate.id,
149
- mandateRevision: input.mandate.revision,
150
- ownerAppId: "core",
151
- ...(!review && input.actionApproval ? { actionApproval: input.actionApproval } : {}),
152
- }
153
- : undefined,
154
- dependencies: input.dependencies,
155
- }),
156
- );
134
+ let dispatched = false;
135
+ const response = await dispatch({
136
+ request,
137
+ kind: input.entry.kind === "query" ? "queries" : "actions",
138
+ origin: "assistant",
139
+ review,
140
+ appId: input.entry.appId,
141
+ capabilityId: input.entry.operation.localId,
142
+ input: input.args,
143
+ authority: {
144
+ actor: input.authority.actor,
145
+ accessSubject: input.authority.accessSubject,
146
+ credentialKind: "session",
147
+ scopes: [],
148
+ },
149
+ mandate: input.mandate
150
+ ? {
151
+ mandateId: input.mandate.id,
152
+ mandateRevision: input.mandate.revision,
153
+ ownerAppId: "core",
154
+ ...(!review && input.actionApproval ? { actionApproval: input.actionApproval } : {}),
155
+ }
156
+ : undefined,
157
+ dependencies: input.dependencies,
158
+ onUpstreamAttempt: () => {
159
+ dispatched = true;
160
+ },
161
+ });
162
+ return await parseCapabilityResponse(response, dispatched);
157
163
  };
158
164
 
159
- export const reviewAiCapability = async (input: AiCapabilityCall): Promise<CapabilityActionReview | null> => {
165
+ export const reviewAiCapability = async (input: AiCapabilityCall): Promise<CapabilityActionReviewData | null> => {
160
166
  if (input.entry.kind !== "action" || !(input.entry.operation as CapabilityActionManifest).review) return null;
161
- return CapabilityActionReviewSchema.parse(await dispatchAiCapability(input, true));
167
+ return CapabilityActionReviewDataSchema.parse(await dispatchAiCapability(input, true));
162
168
  };
163
169
 
164
170
  export const executeAiCapability = (input: AiCapabilityCall): Promise<unknown> => dispatchAiCapability(input, false);
165
171
 
172
+ /** Run only the app-owned read-only outcome Query, once. Invalid answers stay unknown and remain recoverable. */
173
+ export const checkAiCapability = async (
174
+ input: Omit<AiCapabilityCall, "args" | "actionApproval" | "context"> & {
175
+ context: Pick<ToolContext, "callId" | "signal">;
176
+ check: NonNullable<CapabilityActionCallReview["check"]>;
177
+ /** The app's catalog, in which the check Query is looked up; the caller builds it (no import cycle). */
178
+ catalog: readonly AiCapabilityCatalogEntry[];
179
+ },
180
+ ): Promise<AiActionCheck> => {
181
+ try {
182
+ const query = input.catalog.find(
183
+ (entry) => entry.appId === input.entry.appId && entry.kind === "query" && entry.operation.localId === input.check.query,
184
+ );
185
+ if (!query || "idempotency" in query.operation || !capabilityQueryChecksActionOutcome(query.operation))
186
+ throw new Error("Action outcome Query is unavailable.");
187
+ // The Action's audit keeps its own dispatch identity; the Query has its separate platform execution record.
188
+ const result = await dispatchAiCapability({ ...input, turnId: undefined, entry: query, args: input.check.input }, false);
189
+ if (!result || typeof result !== "object" || !("data" in result)) throw new Error("Invalid outcome check envelope.");
190
+ const checked = CapabilityActionCheckSchema.parse(result.data);
191
+ if (checked.receipt && capabilityActionReceiptIssues(checked.receipt, "links" in result ? result.links : undefined).length)
192
+ throw new Error("Invalid outcome check receipt.");
193
+ return { state: checked.outcome, ...(checked.receipt ? { receipt: checked.receipt } : {}), at: new Date().toISOString() };
194
+ } catch {
195
+ return { state: "failed", at: new Date().toISOString() };
196
+ }
197
+ };
198
+
166
199
  /**
167
200
  * A rejected approval never reaches the dispatcher, so the assistant records
168
201
  * the same execution row the dispatcher would have written for the attempt.