@k2b/cloud 0.30.0 → 0.32.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.
- package/package.json +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +64 -39
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +120 -2
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/server/widget-request.ts +27 -0
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
package/src/ai/vision-tool.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { aiMissingChatFileError } from "./file-error";
|
|
3
4
|
import { aiProjectFilePathFromMount } from "./file-mount";
|
|
4
5
|
import { aiFileStore, normalizeAiFilePath } from "./files-store";
|
|
5
6
|
import { PdfPages, renderPdfPages } from "./pdf-render";
|
|
@@ -53,7 +54,7 @@ export const createCloudAiViewImageTool = (options: { resolveModel?: () => Promi
|
|
|
53
54
|
if (snapshotRequired && !snapshot) throw new Error(`Attached image snapshot is unavailable: ${path}`);
|
|
54
55
|
const stored =
|
|
55
56
|
projectFile ?? snapshot ?? (projectPath === null ? await aiFileStore.read({ conversationId: ctx.conversationId, path }) : null);
|
|
56
|
-
if (!stored) throw
|
|
57
|
+
if (!stored) throw aiMissingChatFileError(path, ctx.localToolsEnabled);
|
|
57
58
|
const pdf = stored.mediaType === "application/pdf";
|
|
58
59
|
if (!pdf && !isAiImageMediaType(stored.mediaType)) throw new Error(`${path} is not a supported image or PDF (${stored.mediaType}).`);
|
|
59
60
|
if (!pdf && input.pages !== undefined) throw new Error("pages can only be used with a PDF.");
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { domainToUnicode } from "node:url";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import { toPgTextArray } from "../services/postgres";
|
|
4
|
+
|
|
5
|
+
/** The longest address web_extract and fetch_file accept; a longer one can never be read, so it is never recorded. */
|
|
6
|
+
const WEB_ADDRESS_MAX_CHARS = 2_000;
|
|
7
|
+
|
|
8
|
+
const URL_TOKEN = /https?:\/\/[^\s<>"'`\\^{}|[\]]+/gi;
|
|
9
|
+
/**
|
|
10
|
+
* An address typed without its scheme, such as `example.org/news`. It must start the word: a host inside another
|
|
11
|
+
* address, an email address, or a longer host name is not a typed address of its own.
|
|
12
|
+
*/
|
|
13
|
+
const BARE_TOKEN =
|
|
14
|
+
/(?<![\w@.\-/:%+~#=?&])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9](?::\d{1,5})?(?:\/[^\s<>"'`\\^{}|[\]]*)?/gi;
|
|
15
|
+
|
|
16
|
+
/** Drops punctuation that ends the sentence around an address, and a closing bracket the address did not open. */
|
|
17
|
+
const trimToken = (token: string): string => {
|
|
18
|
+
let end = token.length;
|
|
19
|
+
while (end > 0) {
|
|
20
|
+
const last = token[end - 1]!;
|
|
21
|
+
if (".,;:!?*'\"".includes(last)) end--;
|
|
22
|
+
else if (last === ")" && token.slice(0, end).split("(").length <= token.slice(0, end).split(")").length - 1) end--;
|
|
23
|
+
else break;
|
|
24
|
+
}
|
|
25
|
+
return token.slice(0, end);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The forms of one address that count as the same read: as written, without its fragment, and over HTTPS. */
|
|
29
|
+
const addressForms = (raw: string, add: (address: string) => void) => {
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return;
|
|
37
|
+
const forms = [url.href];
|
|
38
|
+
if (url.hash) forms.push(url.href.slice(0, -url.hash.length));
|
|
39
|
+
// Upgrading to HTTPS carries no data the address did not already carry.
|
|
40
|
+
if (url.protocol === "http:") forms.push(...forms.map((form) => `https:${form.slice("http:".length)}`));
|
|
41
|
+
for (const form of forms) if (form.length <= WEB_ADDRESS_MAX_CHARS) add(form);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every whole address in a text, normalized like a URL the tools read. Only complete tokens count, never a part of a
|
|
46
|
+
* longer address; with `typed`, an address written without its scheme counts too, as a person often types it.
|
|
47
|
+
*/
|
|
48
|
+
export const webAddressesIn = (text: string, options: { typed?: boolean } = {}): Set<string> => {
|
|
49
|
+
const addresses = new Set<string>();
|
|
50
|
+
const add = (address: string) => addresses.add(address);
|
|
51
|
+
const rest = text.replace(URL_TOKEN, (token) => {
|
|
52
|
+
addressForms(trimToken(token), add);
|
|
53
|
+
return " ";
|
|
54
|
+
});
|
|
55
|
+
if (options.typed)
|
|
56
|
+
for (const [token] of rest.matchAll(BARE_TOKEN)) {
|
|
57
|
+
const bare = trimToken(token);
|
|
58
|
+
addressForms(`https://${bare}`, add);
|
|
59
|
+
addressForms(`http://${bare}`, add);
|
|
60
|
+
}
|
|
61
|
+
return addresses;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Records addresses a web search returned or a page read in this chat links to; later reads of them do not ask. */
|
|
65
|
+
export const recordWebAddresses = async (conversationId: string, addresses: Iterable<string>): Promise<void> => {
|
|
66
|
+
const list = [...addresses];
|
|
67
|
+
if (!list.length) return;
|
|
68
|
+
await sql`
|
|
69
|
+
INSERT INTO ai.web_addresses (conversation_id, address)
|
|
70
|
+
SELECT ${conversationId}::uuid, address FROM unnest(${toPgTextArray(list)}::text[]) address
|
|
71
|
+
ON CONFLICT DO NOTHING
|
|
72
|
+
`;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether the chat itself supplied this address: the person wrote it, a web search returned it, or a page read
|
|
77
|
+
* earlier links to it. A message another chat sent counts as neither. Anything else, such as an address the model
|
|
78
|
+
* assembled from other data, has no provenance. `url` is the normalized address the tool reads.
|
|
79
|
+
*/
|
|
80
|
+
export const hasWebAddressProvenance = async (conversationId: string, url: string): Promise<boolean> => {
|
|
81
|
+
const [recorded] = await sql<{ found: boolean }[]>`
|
|
82
|
+
SELECT EXISTS (SELECT 1 FROM ai.web_addresses WHERE conversation_id = ${conversationId}::uuid AND address = ${url}) AS found
|
|
83
|
+
`;
|
|
84
|
+
if (recorded?.found) return true;
|
|
85
|
+
// Only the person's own messages that name the host are read, never tool results.
|
|
86
|
+
const { hostname } = new URL(url);
|
|
87
|
+
const hosts = [...new Set([hostname, domainToUnicode(hostname) || hostname])];
|
|
88
|
+
const rows = await sql<{ text: string }[]>`
|
|
89
|
+
SELECT text FROM (
|
|
90
|
+
SELECT message, meta FROM ai.messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
|
|
91
|
+
UNION ALL
|
|
92
|
+
SELECT message, meta FROM ai.task_messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
|
|
93
|
+
) entry
|
|
94
|
+
CROSS JOIN LATERAL (
|
|
95
|
+
SELECT string_agg(CASE WHEN jsonb_typeof(part) = 'string' THEN part #>> '{}' ELSE part->>'text' END, chr(10)) AS text
|
|
96
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(entry.message->'content') = 'array' THEN entry.message->'content' ELSE '[]'::jsonb END) part
|
|
97
|
+
WHERE jsonb_typeof(part) = 'string' OR part->>'type' = 'text'
|
|
98
|
+
) content
|
|
99
|
+
WHERE NOT COALESCE(entry.meta ? 'agentMessage', false)
|
|
100
|
+
AND EXISTS (SELECT 1 FROM unnest(${toPgTextArray(hosts)}::text[]) host WHERE strpos(lower(content.text), host) > 0)
|
|
101
|
+
`;
|
|
102
|
+
return rows.some((row) => webAddressesIn(row.text, { typed: true }).has(url));
|
|
103
|
+
};
|
|
@@ -5,6 +5,7 @@ import { describeRoute } from "hono-openapi";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { listApps } from "../_internal/registry";
|
|
7
7
|
import { CapabilityAppIdSchema } from "../contracts/capabilities";
|
|
8
|
+
import { AI_INFERENCE_SCOPE } from "../contracts/shared";
|
|
8
9
|
import { type AuthContext, auth, err, respond, v } from "../server";
|
|
9
10
|
import { audit } from "../services/audit";
|
|
10
11
|
import { getIdentitySigningKeyStatus, identityMetrics, revokeIdentitySigningKey, rewrapIdentitySigningKeys } from "../services/identity";
|
|
@@ -63,7 +64,11 @@ const ServiceAccountStatusInputSchema = z.object({ status: z.enum(["active", "di
|
|
|
63
64
|
const ServiceAccountApiKeyInputSchema = z
|
|
64
65
|
.object({
|
|
65
66
|
name: z.string().trim().min(1).max(120),
|
|
66
|
-
scopes: z
|
|
67
|
+
scopes: z
|
|
68
|
+
.array(z.string().trim().min(1).max(64))
|
|
69
|
+
.max(20)
|
|
70
|
+
.default([])
|
|
71
|
+
.refine((scopes) => !scopes.includes(AI_INFERENCE_SCOPE), "ai:inference is reserved for models-only personal API keys"),
|
|
67
72
|
expiresAt: z.iso.datetime().nullable().optional(),
|
|
68
73
|
})
|
|
69
74
|
.strict();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { submitAiTurnAction } from "../ai/runtime";
|
|
2
|
+
import { aiConversations } from "../ai/store";
|
|
3
|
+
import { APP_ACTION_LIMITS } from "../contracts/app-approval";
|
|
4
|
+
import type { AppDeviceActions } from "../services/app-approval";
|
|
5
|
+
import { listPending } from "../services/approvals";
|
|
6
|
+
import { appActionSummary, appActionView } from "../services/approvals/device";
|
|
7
|
+
|
|
8
|
+
export const createDeviceActions = (appName: (appId: string, locale: string) => string): AppDeviceActions => {
|
|
9
|
+
const view: AppDeviceActions["view"] = async ({ userId, issuer, actionId }) => {
|
|
10
|
+
const { items } = await listPending({ approverUserId: userId, id: actionId, limit: 1 });
|
|
11
|
+
return items[0] ? appActionView(items[0], { issuer, appName }) : null;
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
inbox: async ({ userId, issuer }) => {
|
|
15
|
+
const { items, count } = await listPending({ approverUserId: userId, limit: APP_ACTION_LIMITS.inboxActions });
|
|
16
|
+
const views = await Promise.all(items.map((row) => appActionView(row, { issuer, appName })));
|
|
17
|
+
return { actions: views.map(appActionSummary), moreActions: Math.max(0, count - items.length) };
|
|
18
|
+
},
|
|
19
|
+
view,
|
|
20
|
+
decide: async ({ userId, deviceId, issuer, actionId, digest, approved }) => {
|
|
21
|
+
const current = await view({ userId, issuer, actionId });
|
|
22
|
+
if (!current || current.digest !== digest) return false;
|
|
23
|
+
const pending = await aiConversations.findPendingTurnActionByApproval({ approvalId: actionId, ownerUserId: userId });
|
|
24
|
+
if (!pending) return false;
|
|
25
|
+
const result = await submitAiTurnAction({
|
|
26
|
+
conversationId: pending.conversationId,
|
|
27
|
+
turnId: pending.turnId,
|
|
28
|
+
callId: pending.callId,
|
|
29
|
+
action: { type: "approval_response", approved },
|
|
30
|
+
decidedBy: { userId, via: "cloud_login", deviceId },
|
|
31
|
+
signedInSession: false,
|
|
32
|
+
});
|
|
33
|
+
return result.ok;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
};
|
package/src/api/app-approval.ts
CHANGED
|
@@ -16,13 +16,15 @@ import {
|
|
|
16
16
|
} from "../contracts/app-approval";
|
|
17
17
|
import { type AuthContext, auth, rateLimit, v } from "../server";
|
|
18
18
|
import { type AppApprovalActor, AppApprovalError, appApproval, type createAppApprovalService } from "../services/app-approval";
|
|
19
|
+
import { appNameIn } from "../ssr/runtime";
|
|
20
|
+
import { createDeviceActions } from "./app-approval-actions";
|
|
19
21
|
|
|
20
22
|
const PairingIdSchema = z.object({ pairingId: z.string().uuid() }).strict();
|
|
21
23
|
const DevicePageSchema = z.object({ after: z.string().uuid().optional() }).strict();
|
|
22
24
|
type Service = ReturnType<typeof createAppApprovalService>;
|
|
23
25
|
|
|
24
26
|
/** This is a separate credential surface, never a user API-key or OAuth scope. */
|
|
25
|
-
export const createAppApprovalRoutes = (service: Service = appApproval) => {
|
|
27
|
+
export const createAppApprovalRoutes = (service: Service = appApproval, actions = createDeviceActions) => {
|
|
26
28
|
const actor = async (c: Parameters<typeof auth.session.getToken>[0]): Promise<AppApprovalActor> => {
|
|
27
29
|
// The web session (or an explicit bearer session), never the mobile app's session: a phone
|
|
28
30
|
// that pairs sign-in devices would hand out authority that outlives its removal.
|
|
@@ -104,7 +106,13 @@ export const createAppApprovalRoutes = (service: Service = appApproval) => {
|
|
|
104
106
|
"/device",
|
|
105
107
|
describeRoute({ tags: ["App approval"], summary: "Execute a one-use signed device command" }),
|
|
106
108
|
v("json", AppDeviceRequestSchema),
|
|
107
|
-
async (c) =>
|
|
109
|
+
async (c) =>
|
|
110
|
+
c.json(
|
|
111
|
+
await service.deviceCommand(
|
|
112
|
+
c.req.valid("json"),
|
|
113
|
+
actions((appId, locale) => appNameIn(c, appId, locale, appId)),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
108
116
|
)
|
|
109
117
|
.post(
|
|
110
118
|
"/login/start",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import { resolveAiChatRequestContext } from "../ai/request-context";
|
|
4
|
+
import type { ApprovalListQuery, ApprovalPage } from "../contracts/approvals";
|
|
5
|
+
import type { AuthContext } from "../server/middleware/auth";
|
|
6
|
+
import { listPending, presentPendingApproval } from "../services/approvals/list";
|
|
7
|
+
import { getLocalizedRuntimeContext } from "../ssr/runtime";
|
|
8
|
+
|
|
9
|
+
export const loadPendingApprovals = async <T extends AuthContext>(c: Context<T>, query: ApprovalListQuery): Promise<ApprovalPage> => {
|
|
10
|
+
const ctx = await resolveAiChatRequestContext(c);
|
|
11
|
+
if (ctx instanceof Response) throw new HTTPException(403, { res: ctx });
|
|
12
|
+
const page = await listPending({ approverUserId: ctx.ownerUserId, appId: query.app, link: query.link, cursor: query.cursor });
|
|
13
|
+
const apps = getLocalizedRuntimeContext(c).apps;
|
|
14
|
+
return { ...page, items: page.items.map((row) => presentPendingApproval(row, apps)) };
|
|
15
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { describeRoute } from "hono-openapi";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { toAiActionFailureResponse } from "../ai/http";
|
|
6
|
+
import { resolveAiChatRequestContext } from "../ai/request-context";
|
|
7
|
+
import { submitAiTurnAction } from "../ai/runtime";
|
|
8
|
+
import { aiConversations } from "../ai/store";
|
|
9
|
+
import {
|
|
10
|
+
APPROVAL_CLIENT_HEADER,
|
|
11
|
+
ApprovalCountsSchema,
|
|
12
|
+
ApprovalDecisionInputSchema,
|
|
13
|
+
ApprovalDecisionResultSchema,
|
|
14
|
+
ApprovalListQuerySchema,
|
|
15
|
+
ApprovalPageSchema,
|
|
16
|
+
} from "../contracts/approvals";
|
|
17
|
+
import { ErrorResponseSchema } from "../contracts/shared";
|
|
18
|
+
import { rateLimit } from "../server";
|
|
19
|
+
import { respond } from "../server/api";
|
|
20
|
+
import { type AuthContext, auth } from "../server/middleware/auth";
|
|
21
|
+
import { jsonResponse, requiresAuth } from "../server/middleware/openapi";
|
|
22
|
+
import { v } from "../server/middleware/validator";
|
|
23
|
+
import { ok } from "../server/services";
|
|
24
|
+
import { approvalDecisionChannel, countPending } from "../services/approvals";
|
|
25
|
+
import { decodeApprovalCursor } from "../services/approvals/cursor";
|
|
26
|
+
import { loadPendingApprovals } from "./approvals-loader";
|
|
27
|
+
|
|
28
|
+
export const approvalRoutes = new Hono<AuthContext>()
|
|
29
|
+
.use(rateLimit())
|
|
30
|
+
.use(auth.requireRole("authenticated"))
|
|
31
|
+
.get(
|
|
32
|
+
"/",
|
|
33
|
+
describeRoute({
|
|
34
|
+
tags: ["Approvals"],
|
|
35
|
+
summary: "List pending Action approvals",
|
|
36
|
+
...requiresAuth,
|
|
37
|
+
responses: {
|
|
38
|
+
200: jsonResponse(ApprovalPageSchema, "Pending approvals"),
|
|
39
|
+
400: jsonResponse(ErrorResponseSchema, "Invalid query"),
|
|
40
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
41
|
+
403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
v(
|
|
45
|
+
"query",
|
|
46
|
+
ApprovalListQuerySchema.refine((query) => !query.cursor || decodeApprovalCursor(query.cursor) !== null, "Invalid approval cursor."),
|
|
47
|
+
),
|
|
48
|
+
async (c) => respond(c, ok(await loadPendingApprovals(c, c.req.valid("query")))),
|
|
49
|
+
)
|
|
50
|
+
.get(
|
|
51
|
+
"/counts",
|
|
52
|
+
describeRoute({
|
|
53
|
+
tags: ["Approvals"],
|
|
54
|
+
summary: "Count pending Action approvals",
|
|
55
|
+
...requiresAuth,
|
|
56
|
+
responses: {
|
|
57
|
+
200: jsonResponse(ApprovalCountsSchema, "Approval counts"),
|
|
58
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
59
|
+
403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
async (c) => {
|
|
63
|
+
const ctx = await resolveAiChatRequestContext(c);
|
|
64
|
+
if (ctx instanceof Response) return ctx;
|
|
65
|
+
return respond(c, ok(await countPending({ approverUserId: ctx.ownerUserId })));
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
.post(
|
|
69
|
+
"/:id/decision",
|
|
70
|
+
bodyLimit({ maxSize: 1024 }),
|
|
71
|
+
describeRoute({
|
|
72
|
+
tags: ["Approvals"],
|
|
73
|
+
summary: "Decide an Action approval and continue its chat",
|
|
74
|
+
...requiresAuth,
|
|
75
|
+
responses: {
|
|
76
|
+
200: jsonResponse(ApprovalDecisionResultSchema, "Approval decision"),
|
|
77
|
+
400: jsonResponse(ErrorResponseSchema, "Invalid decision"),
|
|
78
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
79
|
+
403: jsonResponse(ErrorResponseSchema, "Decision refused"),
|
|
80
|
+
404: jsonResponse(ErrorResponseSchema, "Open approval not found"),
|
|
81
|
+
409: jsonResponse(ErrorResponseSchema, "Already decided differently"),
|
|
82
|
+
413: jsonResponse(ErrorResponseSchema, "Request body too large"),
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
v("param", z.object({ id: z.uuid() })),
|
|
86
|
+
v("json", ApprovalDecisionInputSchema),
|
|
87
|
+
async (c) => {
|
|
88
|
+
const ctx = await resolveAiChatRequestContext(c);
|
|
89
|
+
if (ctx instanceof Response) return ctx;
|
|
90
|
+
const pending = await aiConversations.findPendingTurnActionByApproval({
|
|
91
|
+
approvalId: c.req.valid("param").id,
|
|
92
|
+
ownerUserId: ctx.ownerUserId,
|
|
93
|
+
});
|
|
94
|
+
if (!pending)
|
|
95
|
+
return toAiActionFailureResponse(c, {
|
|
96
|
+
ok: false,
|
|
97
|
+
status: 404,
|
|
98
|
+
message: "This request has expired — the assistant already moved on.",
|
|
99
|
+
});
|
|
100
|
+
const decision = c.req.valid("json");
|
|
101
|
+
const result = await submitAiTurnAction({
|
|
102
|
+
conversationId: pending.conversationId,
|
|
103
|
+
turnId: pending.turnId,
|
|
104
|
+
callId: pending.callId,
|
|
105
|
+
action: { type: "approval_response", ...decision },
|
|
106
|
+
decidedBy: {
|
|
107
|
+
userId: ctx.ownerUserId,
|
|
108
|
+
via: approvalDecisionChannel({
|
|
109
|
+
credentialKind: c.get("credentialKind"),
|
|
110
|
+
actor: ctx.actor,
|
|
111
|
+
client: c.req.header(APPROVAL_CLIENT_HEADER),
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
toolApprovalContext: ctx.toolApprovalContext,
|
|
115
|
+
signedInSession: ctx.signedInSession,
|
|
116
|
+
});
|
|
117
|
+
if (!result.ok) return toAiActionFailureResponse(c, result);
|
|
118
|
+
return respond(c, ok({ status: decision.approved ? ("approved" as const) : ("rejected" as const) }));
|
|
119
|
+
},
|
|
120
|
+
);
|
package/src/api/capabilities.ts
CHANGED
|
@@ -613,11 +613,12 @@ const runCapabilityDispatch = async (
|
|
|
613
613
|
: schemaValidator(`${operation.schemaHash}:result`, capabilityResultJsonSchema(operation.dataSchema));
|
|
614
614
|
const parsedResult = resultValidator?.safeParse(upstreamBody.data);
|
|
615
615
|
const parsedReview = params.review ? CapabilityActionReviewSchema.safeParse(upstreamBody.data) : null;
|
|
616
|
+
// A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
|
|
616
617
|
const reviewApprovalScopeIsValid =
|
|
617
618
|
!params.review ||
|
|
618
|
-
!("approval" in operation) ||
|
|
619
619
|
!parsedReview?.success ||
|
|
620
|
-
(
|
|
620
|
+
("approval" in operation && operation.approval === "rememberable") ||
|
|
621
|
+
parsedReview.data.approvalScope === undefined;
|
|
621
622
|
if (!resultValidator || !parsedResult?.success || !reviewApprovalScopeIsValid) {
|
|
622
623
|
if (actionWithoutRetrySafety) return settle(outcomeUnknown(), "uncertain");
|
|
623
624
|
const invalid = errorResponse("INVALID_APP_RESPONSE", messages.outsideResultSchema({ appId: params.appId }), 502);
|
package/src/api/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import adminOutgoingMailRoutes from "./admin-outgoing-mail";
|
|
|
33
33
|
import adminRailRoutes from "./admin-rail";
|
|
34
34
|
import { adminAnnouncementRoutes, announcementRoutes } from "./announcements";
|
|
35
35
|
import { createAppApprovalRoutes } from "./app-approval";
|
|
36
|
+
import { approvalRoutes } from "./approvals";
|
|
36
37
|
import { appDiscoveryRoutes } from "./apps";
|
|
37
38
|
import { createAuthRoutes } from "./auth";
|
|
38
39
|
import { createCapabilityRoutes } from "./capabilities";
|
|
@@ -44,6 +45,7 @@ import { createSearchRoutes } from "./search";
|
|
|
44
45
|
import { createSyncOpsProxyRoutes } from "./sync-ops";
|
|
45
46
|
import { createWidgetRoutes } from "./widgets";
|
|
46
47
|
|
|
48
|
+
export { createAiOpenAiRoutes } from "../ai/openai-api";
|
|
47
49
|
export { type CapabilityDispatchDependencies, dispatchCapability } from "./capabilities";
|
|
48
50
|
export { cliPluginListRoutes } from "./cli-plugins";
|
|
49
51
|
export { createPwaPhoneRoutes } from "./pwa-phone";
|
|
@@ -72,6 +74,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
|
|
|
72
74
|
.route("/me", meRoutes)
|
|
73
75
|
.route("/accounts", accountsEntitiesRoutes)
|
|
74
76
|
.route("/apps", appDiscoveryRoutes)
|
|
77
|
+
.route("/approvals", approvalRoutes)
|
|
75
78
|
.route("/announcements", announcementRoutes)
|
|
76
79
|
.route("/admin/core/ai-usage", adminAiUsageRoutes)
|
|
77
80
|
.route("/admin/core/ai-quotas", adminAiQuotaRoutes)
|
package/src/api/me.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* third-party management lives in the accounts admin app.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { ok } from "@k2b/stdlib";
|
|
7
|
+
import { err, fail, ok } from "@k2b/stdlib";
|
|
8
8
|
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
9
9
|
import { createMiddleware } from "hono/factory";
|
|
10
10
|
import { describeRoute } from "hono-openapi";
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
webauthn,
|
|
52
52
|
} from "../services";
|
|
53
53
|
import { MandatePolicyV1Schema } from "../services/mandates";
|
|
54
|
+
import { coreSettings } from "../services/settings/api";
|
|
54
55
|
|
|
55
56
|
import { createMeRailRoutes } from "./me-rail";
|
|
56
57
|
import { createMeWebVitalsRoutes } from "./me-web-vitals";
|
|
@@ -614,13 +615,14 @@ const app = new Hono<AuthContext>()
|
|
|
614
615
|
describeRoute({
|
|
615
616
|
tags: ["Me"],
|
|
616
617
|
summary: "Create current user API key",
|
|
617
|
-
description:
|
|
618
|
+
description:
|
|
619
|
+
"Create a personal API key with full access (default) or models-only access to the OpenAI-compatible API when enabled. The raw token is returned once.",
|
|
618
620
|
...requiresAuth,
|
|
619
621
|
responses: {
|
|
620
622
|
201: jsonResponse(CreateUserApiKeyResponseSchema, "API key created"),
|
|
621
623
|
400: jsonResponse(ErrorResponseSchema, "Failed to create API key"),
|
|
622
624
|
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
623
|
-
403: jsonResponse(ErrorResponseSchema, "
|
|
625
|
+
403: jsonResponse(ErrorResponseSchema, "Credential not permitted or models-only API turned off"),
|
|
624
626
|
},
|
|
625
627
|
}),
|
|
626
628
|
v("json", CreateUserApiKeySchema),
|
|
@@ -630,7 +632,14 @@ const app = new Hono<AuthContext>()
|
|
|
630
632
|
async () => {
|
|
631
633
|
const user = c.get("user");
|
|
632
634
|
const data = c.req.valid("json");
|
|
635
|
+
if (data.access === "models" && !(await coreSettings.get<boolean>("ai.openai_api_enabled")))
|
|
636
|
+
return fail(
|
|
637
|
+
err.forbidden(
|
|
638
|
+
"Models-only API keys are turned off. An administrator can turn on the OpenAI-compatible API in the AI settings.",
|
|
639
|
+
),
|
|
640
|
+
);
|
|
633
641
|
return serviceAccountCredentials.createUserApiToken({
|
|
642
|
+
access: data.access,
|
|
634
643
|
user,
|
|
635
644
|
name: data.name,
|
|
636
645
|
expiresAt: data.expiresAt ?? null,
|
package/src/api/widgets.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { readBoundedJson } from "../_internal/bounded-json";
|
|
3
3
|
import { type DashboardWidget, listWidgets } from "../_internal/registry";
|
|
4
4
|
import {
|
|
5
|
+
type DashboardWidgetSize,
|
|
6
|
+
fitWidgetSize,
|
|
7
|
+
isDashboardWidgetSize,
|
|
5
8
|
WIDGET_MAX_RESPONSE_BYTES,
|
|
6
9
|
type WidgetResponse,
|
|
7
10
|
WidgetResponseSchema,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
widgetKey,
|
|
10
13
|
} from "../contracts/widgets";
|
|
11
14
|
import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
|
|
15
|
+
import { WIDGET_SIZE_PARAM } from "../server/widget-request";
|
|
12
16
|
import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
|
|
13
17
|
import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
|
|
14
18
|
import { widgetInvocationOperation } from "../services/identity/invocation-operations";
|
|
@@ -78,6 +82,8 @@ type ProviderOutcome =
|
|
|
78
82
|
const callProvider = async (options: {
|
|
79
83
|
c: Context<AuthContext>;
|
|
80
84
|
widget: DashboardWidget;
|
|
85
|
+
/** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
|
|
86
|
+
size: unknown;
|
|
81
87
|
token: string;
|
|
82
88
|
requestId: string | undefined;
|
|
83
89
|
signal: AbortSignal;
|
|
@@ -98,6 +104,7 @@ const callProvider = async (options: {
|
|
|
98
104
|
headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
|
|
99
105
|
|
|
100
106
|
const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
|
|
107
|
+
targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
|
|
101
108
|
signal.throwIfAborted();
|
|
102
109
|
const response = await options.fetch(targetUrl, { headers, signal });
|
|
103
110
|
if (response.status === 204 || response.status === 403) {
|
|
@@ -125,6 +132,17 @@ const callProvider = async (options: {
|
|
|
125
132
|
}
|
|
126
133
|
};
|
|
127
134
|
|
|
135
|
+
/**
|
|
136
|
+
* `<appId>/<widgetId>` with an optional `@small`, `@medium`, or `@large`, as the key and the size it asks for. The
|
|
137
|
+
* suffix is a size whenever the key before it is declared; `defineApp()` rejects `@` in widget IDs, so only an app
|
|
138
|
+
* built before sizes existed can declare an ID that ends in such a suffix, and it is still asked by that whole ID.
|
|
139
|
+
*/
|
|
140
|
+
const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [string, DashboardWidgetSize | undefined] => {
|
|
141
|
+
const at = value.lastIndexOf("@");
|
|
142
|
+
const size = at < 0 ? undefined : value.slice(at + 1);
|
|
143
|
+
return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
|
|
144
|
+
};
|
|
145
|
+
|
|
128
146
|
export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
|
|
129
147
|
const registry = dependencies.listWidgets ?? listWidgets;
|
|
130
148
|
const fetchWidget = dependencies.fetch ?? globalThis.fetch;
|
|
@@ -155,17 +173,20 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
155
173
|
.use(rejectReservedWorkloadCredential)
|
|
156
174
|
.use(auth.requireOAuthScope("read", "admin"))
|
|
157
175
|
/**
|
|
158
|
-
* Every requested widget (`?widget=<appId>/<widgetId>`, repeatable
|
|
159
|
-
* NDJSON stream: one line per widget as soon as it answers, each within its own budget.
|
|
176
|
+
* Every requested widget (`?widget=<appId>/<widgetId>`, repeatable, optionally with `@<size>`; all declared
|
|
177
|
+
* widgets without one) as an NDJSON stream: one line per widget as soon as it answers, each within its own budget.
|
|
160
178
|
*/
|
|
161
179
|
.get("/widgets/v1", async (c) => {
|
|
162
180
|
const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
|
|
163
|
-
const requested = new Set(c.req.queries("widget") ?? []);
|
|
164
181
|
const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
|
|
165
182
|
let widgets: DashboardWidget[];
|
|
183
|
+
let requested = new Map<string, DashboardWidgetSize | undefined>();
|
|
166
184
|
try {
|
|
167
185
|
const keys = new Set<string>();
|
|
168
|
-
|
|
186
|
+
const declared = await waitWithin(registry(), setup);
|
|
187
|
+
const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
|
|
188
|
+
requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
|
|
189
|
+
widgets = declared.filter((widget) => {
|
|
169
190
|
const key = widgetKey(widget.appId, widget.widgetId);
|
|
170
191
|
if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
|
|
171
192
|
keys.add(key);
|
|
@@ -222,6 +243,7 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
222
243
|
signal: AbortSignal.any([fanoutSignal, timeout]),
|
|
223
244
|
timeout,
|
|
224
245
|
fetch: fetchWidget,
|
|
246
|
+
size: requested.get(key),
|
|
225
247
|
});
|
|
226
248
|
if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
|
|
227
249
|
// A widget the stream itself stopped has already been settled, and is logged once, below.
|
|
@@ -276,7 +298,16 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
276
298
|
);
|
|
277
299
|
|
|
278
300
|
phase = "provider";
|
|
279
|
-
const outcome = await callProvider({
|
|
301
|
+
const outcome = await callProvider({
|
|
302
|
+
c,
|
|
303
|
+
widget,
|
|
304
|
+
size: c.req.query(WIDGET_SIZE_PARAM),
|
|
305
|
+
token: signed.token,
|
|
306
|
+
requestId,
|
|
307
|
+
signal,
|
|
308
|
+
timeout,
|
|
309
|
+
fetch: fetchWidget,
|
|
310
|
+
});
|
|
280
311
|
if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
|
|
281
312
|
if (!("reason" in outcome))
|
|
282
313
|
return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { LocaleProvider } from "@k2b/ui";
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
import Approvals from "./Approvals";
|
|
4
|
+
|
|
5
|
+
/** Renders the real list for a browser test, which answers the approvals API with invented requests. */
|
|
6
|
+
const host = document.getElementById("root");
|
|
7
|
+
if (!host) throw new Error("Missing harness root");
|
|
8
|
+
const layout = new URLSearchParams(location.search).get("layout") === "band" ? "band" : "list";
|
|
9
|
+
render(
|
|
10
|
+
() => (
|
|
11
|
+
<LocaleProvider locale={document.documentElement.lang || "en"}>
|
|
12
|
+
<Approvals app="assistant" layout={layout} />
|
|
13
|
+
</LocaleProvider>
|
|
14
|
+
),
|
|
15
|
+
host,
|
|
16
|
+
);
|