@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.
Files changed (49) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +180 -37
  3. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +18 -3
  4. package/corpus/templates/design/app/components/layout/Layout.tsx +4 -1
  5. package/corpus/templates/design/app/hooks/use-navigation-state.ts +13 -2
  6. package/corpus/templates/design/app/i18n-data.ts +11 -0
  7. package/corpus/templates/design/app/lib/agent-chat.ts +30 -0
  8. package/corpus/templates/design/app/lib/builder-host-chat.ts +34 -0
  9. package/corpus/templates/design/app/lib/builder-host-origin.ts +31 -0
  10. package/corpus/templates/design/app/lib/embed-chrome.ts +70 -0
  11. package/corpus/templates/design/app/lib/shell-design.ts +113 -0
  12. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +89 -0
  13. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +85 -12
  14. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +43 -19
  15. package/corpus/templates/design/app/pages/design-editor/screen-command-utils.ts +9 -2
  16. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +13 -0
  17. package/corpus/templates/design/app/root.tsx +23 -1
  18. package/corpus/templates/design/server/lib/fusion-screens.ts +17 -1
  19. package/corpus/templates/design/server/plugins/builder-host-embed-headers.ts +37 -0
  20. package/corpus/templates/design/server/routes/[...page].get.ts +1 -0
  21. package/corpus/templates/design/shared/builder-preview-url.ts +113 -0
  22. package/corpus/templates/design/shared/full-app.ts +19 -0
  23. package/corpus/templates/design/shared/shell-screens.ts +139 -0
  24. package/corpus/templates/design/shared/source-mode.ts +10 -0
  25. package/dist/client/RuntimeConfigNotice.js +3 -0
  26. package/dist/client/api-surface.d.ts +19 -0
  27. package/dist/client/api-surface.js +32 -0
  28. package/dist/client/application-state.js +4 -0
  29. package/dist/client/builder-frame.d.ts +6 -0
  30. package/dist/client/builder-frame.js +1 -1
  31. package/dist/client/client-status-requests.js +5 -0
  32. package/dist/client/host/index.d.ts +1 -0
  33. package/dist/client/host/index.js +1 -0
  34. package/dist/client/use-action.d.ts +1 -1
  35. package/dist/client/use-action.js +17 -0
  36. package/dist/client/use-session.js +5 -0
  37. package/dist/collab/awareness.d.ts +2 -2
  38. package/dist/collab/struct-routes.d.ts +1 -1
  39. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  40. package/dist/observability/routes.d.ts +1 -1
  41. package/dist/progress/routes.d.ts +1 -1
  42. package/dist/provider-api/actions/custom-provider-registration.d.ts +13 -13
  43. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  44. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  45. package/dist/resources/handlers.d.ts +1 -1
  46. package/dist/server/realtime-token.d.ts +1 -1
  47. package/dist/server/transcribe-voice.d.ts +1 -1
  48. package/package.json +1 -1
  49. /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
+ };
@@ -10,6 +10,7 @@ import {
10
10
  } from "@agent-native/core/shared";
11
11
  import {
12
12
  defineEventHandler,
13
+ getHeader,
13
14
  getQuery,
14
15
  getRequestURL,
15
16
  setResponseHeader,
@@ -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;
@@ -62,6 +62,6 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
62
62
  summary?: undefined;
63
63
  spans?: undefined;
64
64
  id?: undefined;
65
- error?: undefined;
66
65
  ok: boolean;
66
+ error?: undefined;
67
67
  }>>;
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- ok: boolean;
19
18
  error?: undefined;
19
+ ok: boolean;
20
20
  }>>;