@agent-native/core 0.161.8 → 0.161.9
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/corpus/README.md +1 -1
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +180 -37
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +18 -3
- package/corpus/templates/design/app/components/layout/Layout.tsx +4 -1
- package/corpus/templates/design/app/hooks/use-navigation-state.ts +13 -2
- package/corpus/templates/design/app/i18n-data.ts +11 -0
- package/corpus/templates/design/app/lib/agent-chat.ts +30 -0
- package/corpus/templates/design/app/lib/builder-host-chat.ts +34 -0
- package/corpus/templates/design/app/lib/builder-host-origin.ts +31 -0
- package/corpus/templates/design/app/lib/embed-chrome.ts +70 -0
- package/corpus/templates/design/app/lib/shell-design.ts +113 -0
- package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +89 -0
- package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +85 -12
- package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +43 -19
- package/corpus/templates/design/app/pages/design-editor/screen-command-utils.ts +9 -2
- package/corpus/templates/design/app/pages/design-editor/tool-state.ts +13 -0
- package/corpus/templates/design/app/root.tsx +23 -1
- package/corpus/templates/design/server/lib/fusion-screens.ts +17 -1
- package/corpus/templates/design/server/plugins/builder-host-embed-headers.ts +37 -0
- package/corpus/templates/design/server/routes/[...page].get.ts +1 -0
- package/corpus/templates/design/shared/builder-preview-url.ts +113 -0
- package/corpus/templates/design/shared/full-app.ts +19 -0
- package/corpus/templates/design/shared/shell-screens.ts +139 -0
- package/corpus/templates/design/shared/source-mode.ts +10 -0
- package/dist/client/RuntimeConfigNotice.js +3 -0
- package/dist/client/api-surface.d.ts +19 -0
- package/dist/client/api-surface.js +32 -0
- package/dist/client/application-state.js +4 -0
- package/dist/client/builder-frame.d.ts +6 -0
- package/dist/client/builder-frame.js +1 -1
- package/dist/client/client-status-requests.js +5 -0
- package/dist/client/host/index.d.ts +1 -0
- package/dist/client/host/index.js +1 -0
- package/dist/client/use-action.d.ts +1 -1
- package/dist/client/use-action.js +17 -0
- package/dist/client/use-session.js +5 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
- package/dist/provider-api/actions/provider-api.d.ts +6 -6
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
- /package/corpus/templates/design/app/routes/{visual-edit.$id.tsx → visual-edit_.$id.tsx} +0 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `require-corp` would make the browser reject the containers this canvas frames,
|
|
3
|
+
* and they cannot opt in via CORP. `frame-ancestors` replaces the embed token as
|
|
4
|
+
* the limit on who may embed this route. A `response` hook, not middleware: core
|
|
5
|
+
* sets its security headers from a plugin, which runs later.
|
|
6
|
+
*/
|
|
7
|
+
import { getRequestURL, type H3Event } from "h3";
|
|
8
|
+
|
|
9
|
+
import { SHELL_CANVAS_PATH } from "../../shared/shell-screens.js";
|
|
10
|
+
|
|
11
|
+
const BUILDER_FRAME_ANCESTORS = [
|
|
12
|
+
"https://builder.io",
|
|
13
|
+
"https://*.builder.io",
|
|
14
|
+
"https://*.builder.my",
|
|
15
|
+
"http://localhost:*",
|
|
16
|
+
"http://127.0.0.1:*",
|
|
17
|
+
].join(" ");
|
|
18
|
+
|
|
19
|
+
export function isShellCanvasRequest(event: H3Event): boolean {
|
|
20
|
+
try {
|
|
21
|
+
return getRequestURL(event).pathname === SHELL_CANVAS_PATH;
|
|
22
|
+
// coercion-ok: an unparseable URL is not the shell route.
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default (nitroApp: any): void => {
|
|
29
|
+
nitroApp.hooks.hook("response", (res: Response, event: H3Event) => {
|
|
30
|
+
if (!isShellCanvasRequest(event)) return;
|
|
31
|
+
res.headers.set("Cross-Origin-Embedder-Policy", "unsafe-none");
|
|
32
|
+
res.headers.set(
|
|
33
|
+
"Content-Security-Policy",
|
|
34
|
+
`frame-ancestors ${BUILDER_FRAME_ANCESTORS}`,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for Builder Fusion container preview URLs. Stops the seam becoming
|
|
3
|
+
* an open embed primitive pointed at an internal address. Mirrors
|
|
4
|
+
* builder-internal's list (`packages/app/models/fusion.model.tsx` —
|
|
5
|
+
* search `builderio.xyz`); keep the two in sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const BUILDER_PREVIEW_HOST_SUFFIXES = [
|
|
9
|
+
".fly.dev",
|
|
10
|
+
".builderio.xyz",
|
|
11
|
+
".builderio.dev",
|
|
12
|
+
".builder.codes",
|
|
13
|
+
".builder.my",
|
|
14
|
+
".builder.live",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Development only, and fail closed: the proxy fetches this URL from the
|
|
21
|
+
* server, so in production a loopback host means scanning the Design host
|
|
22
|
+
* itself on a caller's behalf.
|
|
23
|
+
*/
|
|
24
|
+
export function isLoopbackPreviewAllowed(): boolean {
|
|
25
|
+
const nodeEnv =
|
|
26
|
+
typeof process === "undefined" ? undefined : process.env?.NODE_ENV;
|
|
27
|
+
// Wins over the bundler flag: a dev-mode bundle served by a production
|
|
28
|
+
// process is still production.
|
|
29
|
+
if (nodeEnv === "production") return false;
|
|
30
|
+
if (nodeEnv === "development" || nodeEnv === "test") return true;
|
|
31
|
+
const viteEnv = (import.meta as { env?: { DEV?: boolean } }).env;
|
|
32
|
+
return viteEnv?.DEV === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class InvalidBuilderPreviewUrlError extends Error {
|
|
36
|
+
constructor(reason: string) {
|
|
37
|
+
super(`Invalid Builder preview URL: ${reason}`);
|
|
38
|
+
this.name = "InvalidBuilderPreviewUrlError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
43
|
+
return LOOPBACK_HOSTNAMES.has(hostname);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse and validate a preview URL, returning its normalized form. Throws
|
|
48
|
+
* rather than returning null so a rejected URL is never indistinguishable from
|
|
49
|
+
* an absent one — a bad init must not quietly place zero screens and read as an
|
|
50
|
+
* empty design.
|
|
51
|
+
*/
|
|
52
|
+
export function parseBuilderPreviewUrl(raw: unknown): URL {
|
|
53
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
54
|
+
throw new InvalidBuilderPreviewUrlError("must be a non-empty string");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let url: URL;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(raw.trim());
|
|
60
|
+
} catch {
|
|
61
|
+
throw new InvalidBuilderPreviewUrlError(`could not parse "${raw}"`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Credentials would be replayed by the iframe on every request.
|
|
65
|
+
if (url.username || url.password) {
|
|
66
|
+
throw new InvalidBuilderPreviewUrlError("must not embed credentials");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const hostname = url.hostname.toLowerCase();
|
|
70
|
+
if (isLoopbackHostname(hostname) && !isLoopbackPreviewAllowed()) {
|
|
71
|
+
throw new InvalidBuilderPreviewUrlError(
|
|
72
|
+
"loopback hosts are only allowed in development",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const loopback = isLoopbackHostname(hostname);
|
|
76
|
+
|
|
77
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) {
|
|
78
|
+
throw new InvalidBuilderPreviewUrlError(
|
|
79
|
+
`must use https (got "${url.protocol}")`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
!loopback &&
|
|
85
|
+
!BUILDER_PREVIEW_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
|
|
86
|
+
) {
|
|
87
|
+
throw new InvalidBuilderPreviewUrlError(
|
|
88
|
+
`host "${hostname}" is not a recognized Builder preview host`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return url;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Non-throwing form, for UI that wants to branch instead of failing. */
|
|
96
|
+
/**
|
|
97
|
+
* Origin only. `interactiveFrameUrl` carries whatever route the user is
|
|
98
|
+
* previewing, and resolving screen paths against that base nests them under it
|
|
99
|
+
* (`/app.html` + `/about` → `/app.html/about`).
|
|
100
|
+
*/
|
|
101
|
+
export function builderPreviewOrigin(raw: unknown): string {
|
|
102
|
+
return parseBuilderPreviewUrl(raw).origin;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isBuilderPreviewUrl(raw: unknown): boolean {
|
|
106
|
+
try {
|
|
107
|
+
parseBuilderPreviewUrl(raw);
|
|
108
|
+
return true;
|
|
109
|
+
// coercion-ok: "does not parse" is what this predicate reports as false.
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -29,11 +29,23 @@ export const FULL_APP_BUILDING = defineFeatureFlag({
|
|
|
29
29
|
|
|
30
30
|
export type DesignFusionAppStatus = "building" | "ready" | "error";
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* How the linkage was established. `builder-host` (opened from builder.io's
|
|
34
|
+
* embedded Design tab) never provisions or deploys the container — Builder owns
|
|
35
|
+
* it, and Builder's chat session drives it. Absent means `design-app`.
|
|
36
|
+
*/
|
|
37
|
+
export type DesignFusionAppSource = "design-app" | "builder-host";
|
|
38
|
+
|
|
32
39
|
export interface DesignFusionApp {
|
|
33
40
|
/** Builder project id the app branch lives in. */
|
|
34
41
|
projectId: string;
|
|
35
42
|
/** Branch backing this design (one branch per design). */
|
|
36
43
|
branchName: string;
|
|
44
|
+
source?: DesignFusionAppSource;
|
|
45
|
+
/** Builder organization owning the project. Set for `builder-host` designs. */
|
|
46
|
+
builderOrgId?: string;
|
|
47
|
+
/** Builder content id the tab was opened from, when there is one. */
|
|
48
|
+
contentId?: string;
|
|
37
49
|
/** Builder visual-editor URL for the branch (progress/debugging). */
|
|
38
50
|
editorUrl?: string;
|
|
39
51
|
/** Container dev-server URL once the container is ready; iframe-able. */
|
|
@@ -94,10 +106,17 @@ export function readFusionApp(data: unknown): DesignFusionApp | null {
|
|
|
94
106
|
app.status === "ready" || app.status === "error" ? app.status : "building";
|
|
95
107
|
const str = (value: unknown): string | undefined =>
|
|
96
108
|
typeof value === "string" && value ? value : undefined;
|
|
109
|
+
const source: DesignFusionAppSource | undefined =
|
|
110
|
+
app.source === "builder-host" || app.source === "design-app"
|
|
111
|
+
? app.source
|
|
112
|
+
: undefined;
|
|
97
113
|
return {
|
|
98
114
|
projectId,
|
|
99
115
|
branchName,
|
|
100
116
|
status,
|
|
117
|
+
source,
|
|
118
|
+
builderOrgId: str(app.builderOrgId),
|
|
119
|
+
contentId: str(app.contentId),
|
|
101
120
|
editorUrl: str(app.editorUrl),
|
|
102
121
|
previewUrl: str(app.previewUrl),
|
|
103
122
|
statusMessage: str(app.statusMessage),
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the same `{ screens, placedFrames }` shape as `upsertFusionScreens`,
|
|
3
|
+
* but in memory and without a database, for a canvas the host drives entirely
|
|
4
|
+
* over postMessage. Keep the derivations here identical to the server version:
|
|
5
|
+
* a screen that changes shape between the two paths changes fileIds, and the
|
|
6
|
+
* canvas keys selection and frame geometry on those.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CanvasFramePlacement } from "./canvas-frames.js";
|
|
10
|
+
|
|
11
|
+
/** The host-driven canvas route: no design row, no session, no server writes. */
|
|
12
|
+
export const SHELL_CANVAS_PATH = "/visual-edit/shell";
|
|
13
|
+
|
|
14
|
+
/** Mirrors add-localhost-screens' defaults, as the server builder does. */
|
|
15
|
+
export const DEFAULT_SHELL_SCREEN_WIDTH = 1280;
|
|
16
|
+
export const DEFAULT_SHELL_SCREEN_HEIGHT = 900;
|
|
17
|
+
const DEFAULT_SHELL_GAP = 160;
|
|
18
|
+
export const MAX_SHELL_SCREENS = 100;
|
|
19
|
+
|
|
20
|
+
export interface ShellScreen {
|
|
21
|
+
fileId: string;
|
|
22
|
+
filename: string;
|
|
23
|
+
path: string;
|
|
24
|
+
url: string;
|
|
25
|
+
title: string;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ShellScreensResult {
|
|
31
|
+
screens: ShellScreen[];
|
|
32
|
+
placedFrames: Array<{
|
|
33
|
+
fileId: string;
|
|
34
|
+
filename?: string;
|
|
35
|
+
frame: CanvasFramePlacement;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function slugForPath(path: string): string {
|
|
40
|
+
const slug = path
|
|
41
|
+
.replace(/^\/+/, "")
|
|
42
|
+
.replace(/[^a-zA-Z0-9]+/g, "-")
|
|
43
|
+
.replace(/^-+|-+$/g, "")
|
|
44
|
+
.toLowerCase();
|
|
45
|
+
return (slug || "home").slice(0, 80);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function titleFromPath(path: string): string {
|
|
49
|
+
const trimmed = path.replace(/^\/+|\/+$/g, "");
|
|
50
|
+
if (!trimmed) return "Home";
|
|
51
|
+
const last = trimmed.split("/").pop() ?? trimmed;
|
|
52
|
+
return last.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function uniqueFilename(path: string, used: Set<string>): string {
|
|
56
|
+
const base = `fusion-${slugForPath(path)}.html`;
|
|
57
|
+
let filename = base;
|
|
58
|
+
let suffix = 2;
|
|
59
|
+
while (used.has(filename)) {
|
|
60
|
+
filename = `${base.replace(/\.html$/, "")}-${suffix}.html`;
|
|
61
|
+
suffix += 1;
|
|
62
|
+
}
|
|
63
|
+
used.add(filename);
|
|
64
|
+
return filename;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Derived from the filename rather than random: a remount must produce the same
|
|
69
|
+
* ids or the canvas loses selection and frame geometry on every reload.
|
|
70
|
+
*/
|
|
71
|
+
function shellFileId(filename: string): string {
|
|
72
|
+
return `shell-${filename.replace(/\.html$/, "")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildShellScreens(args: {
|
|
76
|
+
previewOrigin: string;
|
|
77
|
+
paths: string[];
|
|
78
|
+
width?: number;
|
|
79
|
+
height?: number;
|
|
80
|
+
startX?: number;
|
|
81
|
+
startY?: number;
|
|
82
|
+
gap?: number;
|
|
83
|
+
}): ShellScreensResult {
|
|
84
|
+
const {
|
|
85
|
+
previewOrigin,
|
|
86
|
+
paths,
|
|
87
|
+
width = DEFAULT_SHELL_SCREEN_WIDTH,
|
|
88
|
+
height = DEFAULT_SHELL_SCREEN_HEIGHT,
|
|
89
|
+
startX = 0,
|
|
90
|
+
startY = 0,
|
|
91
|
+
gap = DEFAULT_SHELL_GAP,
|
|
92
|
+
} = args;
|
|
93
|
+
|
|
94
|
+
const baseWithSlash = previewOrigin.endsWith("/")
|
|
95
|
+
? previewOrigin
|
|
96
|
+
: `${previewOrigin}/`;
|
|
97
|
+
const baseOrigin = new URL(baseWithSlash).origin;
|
|
98
|
+
|
|
99
|
+
const used = new Set<string>();
|
|
100
|
+
const screens: ShellScreen[] = [];
|
|
101
|
+
const placedFrames: ShellScreensResult["placedFrames"] = [];
|
|
102
|
+
const seenPaths = new Set<string>();
|
|
103
|
+
let nextX = startX;
|
|
104
|
+
|
|
105
|
+
// The host assembles this list from a repo parse plus visited URLs, so a bad
|
|
106
|
+
// parse must not mount hundreds of live iframes. Well above any real route
|
|
107
|
+
// count, so it only ever trips on pathological input.
|
|
108
|
+
for (const rawPath of paths.slice(0, MAX_SHELL_SCREENS)) {
|
|
109
|
+
const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
110
|
+
// A duplicate path would otherwise get a second frame stacked on the first.
|
|
111
|
+
if (seenPaths.has(path)) continue;
|
|
112
|
+
seenPaths.add(path);
|
|
113
|
+
|
|
114
|
+
// `\\host` survives the leading-slash strip and resolves protocol-relative,
|
|
115
|
+
// so a route from the host could otherwise place a frame on another origin.
|
|
116
|
+
const resolved = new URL(path.replace(/^\/+/, ""), baseWithSlash);
|
|
117
|
+
if (resolved.origin !== baseOrigin) continue;
|
|
118
|
+
|
|
119
|
+
const filename = uniqueFilename(path, used);
|
|
120
|
+
const fileId = shellFileId(filename);
|
|
121
|
+
screens.push({
|
|
122
|
+
fileId,
|
|
123
|
+
filename,
|
|
124
|
+
path,
|
|
125
|
+
url: resolved.toString(),
|
|
126
|
+
title: titleFromPath(path),
|
|
127
|
+
width,
|
|
128
|
+
height,
|
|
129
|
+
});
|
|
130
|
+
placedFrames.push({
|
|
131
|
+
fileId,
|
|
132
|
+
filename,
|
|
133
|
+
frame: { fileId, filename, x: nextX, y: startY, width, height },
|
|
134
|
+
});
|
|
135
|
+
nextX += width + gap;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { screens, placedFrames };
|
|
139
|
+
}
|
|
@@ -420,6 +420,16 @@ export function isDesignSourceType(value: unknown): value is DesignSourceType {
|
|
|
420
420
|
);
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Screens whose content is a URL into a running app. Their markup lives in the
|
|
425
|
+
* app's source, so every edit is a handoff, never a design-file write — the
|
|
426
|
+
* distinction the editor keeps having to make, and keeps making per-call-site.
|
|
427
|
+
*/
|
|
428
|
+
export function isRunningAppSourceType(value: unknown): boolean {
|
|
429
|
+
const sourceType = normalizeDesignSourceType(value);
|
|
430
|
+
return sourceType === "localhost" || sourceType === "fusion";
|
|
431
|
+
}
|
|
432
|
+
|
|
423
433
|
export function normalizeDesignSourceType(
|
|
424
434
|
value: unknown,
|
|
425
435
|
): DesignSourceType | null {
|
|
@@ -3,6 +3,7 @@ import { IconAlertCircle, IconAlertTriangle, IconCheck, IconChevronDown, IconChe
|
|
|
3
3
|
import { useEffect, useMemo, useState } from "react";
|
|
4
4
|
import { parseRuntimeConfigReport, } from "../shared/runtime-config.js";
|
|
5
5
|
import { agentNativePath } from "./api-path.js";
|
|
6
|
+
import { agentNativeApiDisabledReason } from "./api-surface.js";
|
|
6
7
|
import { writeClipboardText } from "./clipboard.js";
|
|
7
8
|
import { useT } from "./i18n.js";
|
|
8
9
|
function injectedAppConfig() {
|
|
@@ -31,6 +32,8 @@ export function RuntimeConfigNotice() {
|
|
|
31
32
|
useEffect(() => {
|
|
32
33
|
if (typeof window.fetch !== "function")
|
|
33
34
|
return;
|
|
35
|
+
if (agentNativeApiDisabledReason())
|
|
36
|
+
return;
|
|
34
37
|
const controller = new AbortController();
|
|
35
38
|
let active = true;
|
|
36
39
|
const timeout = window.setTimeout(() => controller.abort(), 5000);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Some surfaces are framed by a host and carry no agent-native session at all —
|
|
3
|
+
* Builder's Design tab frames a canvas that owns no design row and holds no
|
|
4
|
+
* credential. Every `/_agent-native/*` call from one is unauthenticated by
|
|
5
|
+
* construction, so the client must not make them: a 401 per poll buries real
|
|
6
|
+
* failures, and a write can never land.
|
|
7
|
+
*/
|
|
8
|
+
/** Pass `null` to re-enable, so a surface can be entered and left. */
|
|
9
|
+
export declare function setAgentNativeApiDisabled(reason: string | null): void;
|
|
10
|
+
export declare function agentNativeApiDisabledReason(): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Thrown rather than resolved: a caller that cannot tell "no backend" from
|
|
13
|
+
* "empty result" reports success for work that never happened.
|
|
14
|
+
*/
|
|
15
|
+
export declare class AgentNativeApiDisabledError extends Error {
|
|
16
|
+
readonly reason: string;
|
|
17
|
+
constructor(detail: string);
|
|
18
|
+
}
|
|
19
|
+
export declare function assertAgentNativeApiEnabled(detail: string): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Some surfaces are framed by a host and carry no agent-native session at all —
|
|
3
|
+
* Builder's Design tab frames a canvas that owns no design row and holds no
|
|
4
|
+
* credential. Every `/_agent-native/*` call from one is unauthenticated by
|
|
5
|
+
* construction, so the client must not make them: a 401 per poll buries real
|
|
6
|
+
* failures, and a write can never land.
|
|
7
|
+
*/
|
|
8
|
+
let disabledReason = null;
|
|
9
|
+
/** Pass `null` to re-enable, so a surface can be entered and left. */
|
|
10
|
+
export function setAgentNativeApiDisabled(reason) {
|
|
11
|
+
disabledReason = reason?.trim() ? reason.trim() : null;
|
|
12
|
+
}
|
|
13
|
+
export function agentNativeApiDisabledReason() {
|
|
14
|
+
return disabledReason;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Thrown rather than resolved: a caller that cannot tell "no backend" from
|
|
18
|
+
* "empty result" reports success for work that never happened.
|
|
19
|
+
*/
|
|
20
|
+
export class AgentNativeApiDisabledError extends Error {
|
|
21
|
+
reason;
|
|
22
|
+
constructor(detail) {
|
|
23
|
+
const reason = disabledReason ?? "unknown surface";
|
|
24
|
+
super(`agent-native API is disabled on this surface (${reason}): ${detail}`);
|
|
25
|
+
this.name = "AgentNativeApiDisabledError";
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function assertAgentNativeApiEnabled(detail) {
|
|
30
|
+
if (disabledReason)
|
|
31
|
+
throw new AgentNativeApiDisabledError(detail);
|
|
32
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { agentNativePath } from "./api-path.js";
|
|
2
|
+
import { assertAgentNativeApiEnabled } from "./api-surface.js";
|
|
2
3
|
const APP_STATE_KEY_PATTERN = /^[a-zA-Z0-9_:-]+$/;
|
|
3
4
|
function appStateUrl(key) {
|
|
4
5
|
if (!APP_STATE_KEY_PATTERN.test(key)) {
|
|
@@ -63,6 +64,7 @@ function jsonBody(value) {
|
|
|
63
64
|
/** Server caps a batch at 100 keys; stay under it when splitting. */
|
|
64
65
|
const MAX_BATCH_KEYS = 100;
|
|
65
66
|
export async function readClientAppStateMany(keys, options = {}) {
|
|
67
|
+
assertAgentNativeApiEnabled(`read application state [${keys.join(", ")}]`);
|
|
66
68
|
const unique = [...new Set(keys)];
|
|
67
69
|
for (const key of unique)
|
|
68
70
|
appStateUrl(key); // validates the key shape
|
|
@@ -142,6 +144,7 @@ export async function readClientAppState(key, options = {}) {
|
|
|
142
144
|
return (batch.values[key] ?? null);
|
|
143
145
|
}
|
|
144
146
|
export async function writeClientAppState(key, value, options = {}) {
|
|
147
|
+
assertAgentNativeApiEnabled(`write application state "${key}"`);
|
|
145
148
|
const response = await fetch(appStateUrl(key), {
|
|
146
149
|
method: "PUT",
|
|
147
150
|
headers: buildHeaders(options.requestSource),
|
|
@@ -152,6 +155,7 @@ export async function writeClientAppState(key, value, options = {}) {
|
|
|
152
155
|
return parseAppStateResponse(response, `Write application state "${key}"`);
|
|
153
156
|
}
|
|
154
157
|
export async function deleteClientAppState(key, options = {}) {
|
|
158
|
+
assertAgentNativeApiEnabled(`delete application state "${key}"`);
|
|
155
159
|
const response = await fetch(appStateUrl(key), {
|
|
156
160
|
method: "DELETE",
|
|
157
161
|
// DELETE carries no JSON body, so this custom header is the only
|
|
@@ -19,6 +19,12 @@ export interface BuilderChatMessage {
|
|
|
19
19
|
submit?: boolean;
|
|
20
20
|
mode?: "act" | "plan";
|
|
21
21
|
requestMode?: "act" | "plan";
|
|
22
|
+
/**
|
|
23
|
+
* Origin an embedder already verified for itself. `getBuilderParentOrigin()`
|
|
24
|
+
* needs `?builder.*` params to trust a loopback parent, which a handshake-based
|
|
25
|
+
* embed never carries — without this the message falls back to `"*"`.
|
|
26
|
+
*/
|
|
27
|
+
targetOrigin?: string;
|
|
22
28
|
}
|
|
23
29
|
export declare function sendToBuilderChat(opts: BuilderChatMessage): boolean;
|
|
24
30
|
/**
|
|
@@ -116,7 +116,7 @@ export function sendToBuilderChat(opts) {
|
|
|
116
116
|
if (typeof window === "undefined" || !opts.message?.trim())
|
|
117
117
|
return false;
|
|
118
118
|
const hasParentFrame = window.parent !== window;
|
|
119
|
-
const targetOrigin = getBuilderParentOrigin() ?? "*";
|
|
119
|
+
const targetOrigin = opts.targetOrigin ?? getBuilderParentOrigin() ?? "*";
|
|
120
120
|
const payload = {
|
|
121
121
|
type: "builder.submitChat",
|
|
122
122
|
data: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { agentNativePath } from "./api-path.js";
|
|
2
|
+
import { agentNativeApiDisabledReason } from "./api-surface.js";
|
|
2
3
|
const RESULT_TTL_MS = 500;
|
|
3
4
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
4
5
|
const cache = new Map();
|
|
@@ -31,6 +32,10 @@ function installInvalidationListeners() {
|
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
async function fetchClientStatus(path) {
|
|
35
|
+
// "unavailable" rather than a fabricated payload: callers already treat it as
|
|
36
|
+
// "could not read", and there is genuinely nothing to read here.
|
|
37
|
+
if (agentNativeApiDisabledReason())
|
|
38
|
+
return { state: "unavailable" };
|
|
34
39
|
installInvalidationListeners();
|
|
35
40
|
const url = agentNativePath(path);
|
|
36
41
|
const cached = cache.get(url);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { initializeAgentNativeClient } from "../client-bootstrap.js";
|
|
2
|
+
export { agentNativeApiDisabledReason, AgentNativeApiDisabledError, setAgentNativeApiDisabled, } from "../api-surface.js";
|
|
2
3
|
export { ensureEmbedAuthFetchInterceptor, getEmbedAuthToken, isEmbedAuthActive, isEmbedMcpChatBridgeActive, } from "../embed-auth.js";
|
|
3
4
|
export { sendToFrame, onFrameMessage, requestUserInfo, getFrameOrigin, getFramePostMessageTargetOrigin, getCallbackOrigin, oauthRedirectUri, isInFrame, enterStyleEditing, enterTextEditing, exitSelectionMode, type UserInfo, } from "../frame.js";
|
|
4
5
|
export { getBuilderParentOrigin, isInBuilderFrame, sendToBuilderChat, type BuilderChatMessage, } from "../builder-frame.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { initializeAgentNativeClient } from "../client-bootstrap.js";
|
|
2
|
+
export { agentNativeApiDisabledReason, AgentNativeApiDisabledError, setAgentNativeApiDisabled, } from "../api-surface.js";
|
|
2
3
|
export { ensureEmbedAuthFetchInterceptor, getEmbedAuthToken, isEmbedAuthActive, isEmbedMcpChatBridgeActive, } from "../embed-auth.js";
|
|
3
4
|
export { sendToFrame, onFrameMessage, requestUserInfo, getFrameOrigin, getFramePostMessageTargetOrigin, getCallbackOrigin, oauthRedirectUri, isInFrame, enterStyleEditing, enterTextEditing, exitSelectionMode, } from "../frame.js";
|
|
4
5
|
export { getBuilderParentOrigin, isInBuilderFrame, sendToBuilderChat, } from "../builder-frame.js";
|
|
@@ -73,7 +73,7 @@ export declare const ACTION_KEEPALIVE_BODY_BUDGET_BYTES = 48000;
|
|
|
73
73
|
* `/_agent-native/actions/*` in components.
|
|
74
74
|
*/
|
|
75
75
|
export declare function callAction<TResult = undefined, TName extends ActionName = ActionName>(actionName: TName, params?: ActionParams<TName>, options?: ClientActionCallOptions): Promise<TResult extends undefined ? ActionResult<TName> : TResult>;
|
|
76
|
-
export type KeepaliveActionCallRejectionReason = "body-too-large" | "budget-exhausted";
|
|
76
|
+
export type KeepaliveActionCallRejectionReason = "body-too-large" | "budget-exhausted" | "api-disabled";
|
|
77
77
|
export type KeepaliveActionCallResult<TResult> = {
|
|
78
78
|
accepted: true;
|
|
79
79
|
bodyBytes: number;
|
|
@@ -27,6 +27,7 @@ import { getAnalyticsClientPlatform } from "./analytics-platform.js";
|
|
|
27
27
|
import { getOrCreateAnalyticsSessionId } from "./analytics-session.js";
|
|
28
28
|
import { trackEvent } from "./analytics.js";
|
|
29
29
|
import { agentNativePath } from "./api-path.js";
|
|
30
|
+
import { agentNativeApiDisabledReason, assertAgentNativeApiEnabled, } from "./api-surface.js";
|
|
30
31
|
import { getBrowserTabId } from "./browser-tab-id.js";
|
|
31
32
|
import { clientBuildId, clientCompatibilityVersion, reloadForClientCompatibilityMismatch, } from "./build-compatibility.js";
|
|
32
33
|
import { ensureEmbedAuthFetchInterceptor } from "./embed-auth.js";
|
|
@@ -398,6 +399,7 @@ function shouldTrackActionResponse(error, durationMs, response) {
|
|
|
398
399
|
return Math.random() < rate;
|
|
399
400
|
}
|
|
400
401
|
async function actionFetch(name, method, params, options) {
|
|
402
|
+
assertAgentNativeApiEnabled(`${method} ${name}`);
|
|
401
403
|
const startedAt = actionTelemetryNow();
|
|
402
404
|
let response;
|
|
403
405
|
let responseAt;
|
|
@@ -501,6 +503,16 @@ export function callAction(actionName, params, options = {}) {
|
|
|
501
503
|
export function tryCallActionKeepalive(actionName, params, options = {}) {
|
|
502
504
|
const serializedBody = JSON.stringify(params ?? {});
|
|
503
505
|
const bodyBytes = utf8ByteLength(serializedBody);
|
|
506
|
+
// Reported as a refusal rather than thrown: callers keep the work queued on
|
|
507
|
+
// `accepted: false`, which is the honest outcome for a surface with no backend.
|
|
508
|
+
if (agentNativeApiDisabledReason()) {
|
|
509
|
+
return {
|
|
510
|
+
accepted: false,
|
|
511
|
+
bodyBytes,
|
|
512
|
+
reason: "api-disabled",
|
|
513
|
+
completion: null,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
504
516
|
if (bodyBytes > ACTION_KEEPALIVE_BODY_BUDGET_BYTES) {
|
|
505
517
|
return {
|
|
506
518
|
accepted: false,
|
|
@@ -547,6 +559,10 @@ export function tryCallActionKeepalive(actionName, params, options = {}) {
|
|
|
547
559
|
* ```
|
|
548
560
|
*/
|
|
549
561
|
export function useActionQuery(actionName, params, options) {
|
|
562
|
+
// Not `enabled: false` via options: a disabled surface must win over whatever
|
|
563
|
+
// the caller asked for, and an unfired query reads as "no data" rather than
|
|
564
|
+
// as an error the UI has to special-case.
|
|
565
|
+
const apiDisabled = Boolean(agentNativeApiDisabledReason());
|
|
550
566
|
return useQuery({
|
|
551
567
|
queryKey: ["action", actionName, params],
|
|
552
568
|
// Thread React Query's per-fetch AbortSignal into the network request so
|
|
@@ -556,6 +572,7 @@ export function useActionQuery(actionName, params, options) {
|
|
|
556
572
|
retry: defaultActionQueryRetry,
|
|
557
573
|
retryDelay: defaultActionQueryRetryDelay,
|
|
558
574
|
...options,
|
|
575
|
+
...(apiDisabled ? { enabled: false } : {}),
|
|
559
576
|
});
|
|
560
577
|
}
|
|
561
578
|
// ---------------------------------------------------------------------------
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
2
|
import { setSentryUser, trackSessionStatus } from "./analytics.js";
|
|
3
|
+
import { agentNativeApiDisabledReason } from "./api-surface.js";
|
|
3
4
|
import { fetchAuthSessionStatus, invalidateClientStatusRequest, } from "./client-status-requests.js";
|
|
4
5
|
import { getFrameOrigin, getFramePostMessageTargetOrigin } from "./frame.js";
|
|
5
6
|
const SESSION_CACHE_TTL_MS = 30_000;
|
|
@@ -98,6 +99,10 @@ export function notifySessionInvalidated() {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
function fetchSharedSession() {
|
|
102
|
+
// A surface with no agent-native backend is genuinely signed out. `undefined`
|
|
103
|
+
// would read as "unavailable" and retry until it gave up with an error.
|
|
104
|
+
if (agentNativeApiDisabledReason())
|
|
105
|
+
return Promise.resolve(null);
|
|
101
106
|
if (hasFreshSessionCache())
|
|
102
107
|
return Promise.resolve(cachedSession ?? null);
|
|
103
108
|
if (sessionRequest)
|
|
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
|
|
|
62
62
|
error: string;
|
|
63
63
|
states?: undefined;
|
|
64
64
|
} | {
|
|
65
|
-
error?: undefined;
|
|
66
65
|
states: {
|
|
67
66
|
clientId: number;
|
|
68
67
|
state: string;
|
|
69
68
|
}[];
|
|
69
|
+
error?: undefined;
|
|
70
70
|
}>>;
|
|
71
71
|
/**
|
|
72
72
|
* GET /_agent-native/collab/:docId/users
|
|
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
|
|
|
77
77
|
error: string;
|
|
78
78
|
users?: undefined;
|
|
79
79
|
} | {
|
|
80
|
-
error?: undefined;
|
|
81
80
|
users: {
|
|
82
81
|
clientId: number;
|
|
83
82
|
lastSeen: number;
|
|
84
83
|
}[];
|
|
84
|
+
error?: undefined;
|
|
85
85
|
}>>;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
|
|
14
14
|
*/
|
|
15
15
|
export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
16
|
-
ok?: undefined;
|
|
17
16
|
error: string;
|
|
17
|
+
ok?: undefined;
|
|
18
18
|
} | {
|
|
19
19
|
error?: undefined;
|
|
20
20
|
ok: boolean;
|
|
@@ -17,11 +17,11 @@ declare const _default: import("../../action.js").ActionDefinition<{
|
|
|
17
17
|
id?: undefined;
|
|
18
18
|
provider?: undefined;
|
|
19
19
|
} | {
|
|
20
|
-
error?: undefined;
|
|
21
20
|
configured?: undefined;
|
|
22
21
|
connectPath?: undefined;
|
|
23
22
|
url: string;
|
|
24
23
|
id: string;
|
|
25
24
|
provider: string;
|
|
25
|
+
error?: undefined;
|
|
26
26
|
}>;
|
|
27
27
|
export default _default;
|