diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index 132454c521..c1045c2b6c 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -19,6 +19,7 @@ import { createTriggerCallbackRoutes } from "./trigger-callback"; import { createVirtualMcpRoutes } from "./virtual-mcp"; import { createVmEventsRoutes } from "./vm-events"; import { createVmExecRoutes } from "./vm-exec"; +import { createVmFileRoutes } from "./vm-file"; import { createVmSetupRoutes } from "./vm-setup"; interface OrgScopedDeps { @@ -66,6 +67,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { app.route("/", createKVRoutes({ kvStorage: deps.kvStorage })); app.route("/vm-events", createVmEventsRoutes()); // /api/:org/vm-events app.route("/vm-exec", createVmExecRoutes()); // /api/:org/vm-exec/{exec,kill}/:script + app.route("/vm-file", createVmFileRoutes()); // /api/:org/vm-file/{write,read} app.route("/vm-setup", createVmSetupRoutes()); // /api/:org/vm-setup/:step app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso) diff --git a/apps/mesh/src/api/routes/vm-file.ts b/apps/mesh/src/api/routes/vm-file.ts new file mode 100644 index 0000000000..865ba349cb --- /dev/null +++ b/apps/mesh/src/api/routes/vm-file.ts @@ -0,0 +1,90 @@ +/** + * Browser-facing proxy for the daemon's file read/write endpoints. + * + * Same auth + claim + proxy pattern as `vm-exec.ts`. The browser doesn't hold + * the daemon bearer token, so we authenticate the user and forward through + * `runner.proxyDaemonRequest`. + */ + +import { Hono, type Context } from "hono"; +import { composeSandboxRef } from "@decocms/sandbox/runner"; +import { computeClaimHandle } from "../../sandbox/claim-handle"; +import { getOrInitSharedRunner } from "../../sandbox/lifecycle"; +import { + getUserId, + requireAuth, + requireOrganization, +} from "../../core/mesh-context"; +import type { Env } from "../hono-env"; + +async function proxy(c: Context, daemonPath: string) { + const ctx = c.var.meshContext; + try { + requireAuth(ctx); + } catch { + return c.json({ error: "Unauthorized" }, 401); + } + const userId = getUserId(ctx); + if (!userId) return c.json({ error: "Unauthorized" }, 401); + + let organization: ReturnType; + try { + organization = requireOrganization(ctx); + } catch { + return c.json({ error: "Organization scope required" }, 403); + } + + const virtualMcpId = c.req.query("virtualMcpId"); + const branch = c.req.query("branch"); + if (!virtualMcpId || !branch) { + return c.json({ error: "virtualMcpId and branch are required" }, 400); + } + + const virtualMcp = await ctx.storage.virtualMcps.findById(virtualMcpId); + if (!virtualMcp || virtualMcp.organization_id !== organization.id) { + return c.json({ error: "Virtual MCP not found" }, 404); + } + + const projectRef = composeSandboxRef({ + orgId: organization.id, + virtualMcpId, + branch, + }); + const claimName = computeClaimHandle({ userId, projectRef }, branch); + + const runner = await getOrInitSharedRunner(); + if (!runner) { + return c.json({ error: "No sandbox runner configured" }, 503); + } + + // Daemon expects base64-encoded JSON bodies (Cloudflare WAF bypass). + const rawBody = await c.req.text(); + const encodedBody = Buffer.from(rawBody, "utf-8").toString("base64"); + + let upstream: Response; + try { + upstream = await runner.proxyDaemonRequest(claimName, daemonPath, { + method: "POST", + headers: new Headers({ "content-type": "application/json" }), + body: encodedBody, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return c.json({ error: `Daemon unreachable: ${message}` }, 502); + } + + const text = await upstream.text(); + return new Response(text, { + status: upstream.status, + headers: { "content-type": "application/json" }, + }); +} + +export const createVmFileRoutes = () => { + const app = new Hono(); + + app.post("/write", (c) => proxy(c, "/_decopilot_vm/write")); + app.post("/read", (c) => proxy(c, "/_decopilot_vm/read")); + + return app; +}; diff --git a/apps/mesh/src/web/components/chat/highlight/common/use-multipart-decision-form.ts b/apps/mesh/src/web/components/chat/highlight/common/use-multipart-decision-form.ts index 8d8015efb9..9e6bcf958e 100644 --- a/apps/mesh/src/web/components/chat/highlight/common/use-multipart-decision-form.ts +++ b/apps/mesh/src/web/components/chat/highlight/common/use-multipart-decision-form.ts @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { type FieldValues, type UseFormReturn, useForm } from "react-hook-form"; import type { ZodTypeAny } from "zod"; import { @@ -114,10 +114,15 @@ export function useMultiPartDecisionForm( })(); }; + // Stable ref so the effect below doesn't re-fire on every render. + const flushRef = useRef(flush); + // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- keep ref in sync with latest closure + flushRef.current = flush; + const submit = () => { if (!canSubmit) return; setPendingFlush(false); - flush(); + flushRef.current(); }; const submitOrAdvance = () => { @@ -130,7 +135,7 @@ export function useMultiPartDecisionForm( return; } setPendingFlush(false); - flush(); + flushRef.current(); } else { const nextKey = findNextUnansweredKey( parts, @@ -145,10 +150,9 @@ export function useMultiPartDecisionForm( // Auto-flush deferred submissions when the stream finishes. The // streaming-finished transition is an external event, so this is a - // legitimate effect. `flush` is in deps so the latest closure (with - // current `parts` / `onSubmit`) fires when `isStreaming` flips false; - // `pendingFlush` gates one-shot semantics so spurious re-runs from - // `flush` identity changes short-circuit at the first guard. + // legitimate effect. `flushRef` is stable so the dep array only + // reacts to the meaningful state changes (`pendingFlush`, `isStreaming`, + // `allAnswered`). The ref always holds the latest closure. // oxlint-disable-next-line ban-use-effect/ban-use-effect -- streaming-finished is an external event useEffect(() => { if (!pendingFlush) return; @@ -158,8 +162,8 @@ export function useMultiPartDecisionForm( return; } setPendingFlush(false); - flush(); - }, [pendingFlush, isStreaming, allAnswered, flush]); + flushRef.current(); + }, [pendingFlush, isStreaming, allAnswered]); return { form, diff --git a/apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx new file mode 100644 index 0000000000..84a645556b --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/any-of-field.tsx @@ -0,0 +1,72 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function AnyOfField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + // ── block-ref mode (anyOfRefs from schema resolution) ───────────── + if (schema.anyOfRefs && schema.anyOfRefs.length > 0) { + const refs = schema.anyOfRefs; + const currentRt = + value !== null && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as Record).__resolveType === "string" + ? ((value as Record).__resolveType as string) + : (refs[0]?.resolveType ?? ""); + + const handleRefChange = (rt: string) => { + onChange({ __resolveType: rt }); + }; + + return ( +
+ + + {schema.description && ( +

{schema.description}

+ )} +
+ ); + } + + // ── Fallback: render a basic text input for unresolved anyOf fields ── + return ( +
+ + onChange(e.target.value)} + className="w-full rounded-md border bg-background px-3 py-1.5 text-sm" + placeholder={schema.description ?? ""} + /> + {schema.description && ( +

{schema.description}

+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx new file mode 100644 index 0000000000..ace87891b0 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/array-field.tsx @@ -0,0 +1,90 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { Plus, Trash01 } from "@untitledui/icons"; +import type { FieldProps } from "./field-props"; +import { SchemaForm } from "../schema-form"; +import { renderField } from "../schema-form"; + +export function ArrayField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const items = Array.isArray(value) ? value : []; + const itemSchema = schema.items; + + const addItem = () => { + const t = itemSchema?.type; + const defaultVal = + itemSchema?.default !== undefined + ? itemSchema.default + : t === "object" + ? {} + : t === "number" || t === "integer" + ? 0 + : t === "boolean" + ? false + : t === "array" + ? [] + : ""; + onChange([...items, defaultVal]); + }; + + const removeItem = (index: number) => { + onChange(items.filter((_, i) => i !== index)); + }; + + const updateItem = (index: number, val: unknown) => { + const next = [...items]; + next[index] = val; + onChange(next); + }; + + return ( +
+
+ {label} + +
+ {schema.description && ( +

{schema.description}

+ )} + {items.map((item, i) => ( +
+ + {itemSchema?.type === "object" && itemSchema.properties ? ( + updateItem(i, val)} + basePath={`${path}.${i}`} + /> + ) : itemSchema ? ( + renderField({ + schema: itemSchema, + value: item, + onChange: (val) => updateItem(i, val), + path: `${path}.${i}`, + label: itemSchema.title ?? `Item ${i + 1}`, + }) + ) : null} +
+ ))} + {items.length === 0 && ( +

No items yet.

+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx new file mode 100644 index 0000000000..416e280c46 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/boolean-field.tsx @@ -0,0 +1,29 @@ +import { Switch } from "@deco/ui/components/switch.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function BooleanField({ + value, + onChange, + path, + label, + schema, +}: FieldProps) { + const checked = typeof value === "boolean" ? value : false; + + return ( +
+
+ + {schema.description && ( +

{schema.description}

+ )} +
+ onChange(v)} + /> +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx new file mode 100644 index 0000000000..df335b8297 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/enum-field.tsx @@ -0,0 +1,48 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@deco/ui/components/select.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function EnumField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const options = schema.enum ?? []; + const strValue = value != null ? String(value) : ""; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/field-props.ts b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts new file mode 100644 index 0000000000..2bcc99cd63 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/field-props.ts @@ -0,0 +1,9 @@ +import type { SchemaProperty } from "../resolve-schema"; + +export interface FieldProps { + schema: SchemaProperty; + value: unknown; + onChange: (value: unknown) => void; + path: string; + label: string; +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/image-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/image-field.tsx new file mode 100644 index 0000000000..51d76df5b6 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/image-field.tsx @@ -0,0 +1,42 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function ImageField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const strValue = typeof value === "string" ? value : ""; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +
+ onChange(e.target.value)} + placeholder="https://..." + className="flex-1" + /> + {strValue && ( + {label} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
+
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/number-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/number-field.tsx new file mode 100644 index 0000000000..9caeac0876 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/number-field.tsx @@ -0,0 +1,31 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function NumberField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const numValue = typeof value === "number" ? value : ""; + + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} + { + const v = e.target.value; + onChange(v === "" ? undefined : Number(v)); + }} + /> +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx new file mode 100644 index 0000000000..ed1de46528 --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/object-field.tsx @@ -0,0 +1,43 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "@untitledui/icons"; +import type { FieldProps } from "./field-props"; +import { SchemaForm } from "../schema-form"; + +export function ObjectField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const [open, setOpen] = useState(false); + const objValue = + value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + if (!schema.properties) return null; + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/string-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/string-field.tsx new file mode 100644 index 0000000000..2558a0f00e --- /dev/null +++ b/apps/mesh/src/web/components/sections-editor/fields/string-field.tsx @@ -0,0 +1,78 @@ +import { Input } from "@deco/ui/components/input.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { Label } from "@deco/ui/components/label.tsx"; +import type { FieldProps } from "./field-props"; + +export function StringField({ + schema, + value, + onChange, + path, + label, +}: FieldProps) { + const strValue = typeof value === "string" ? value : ""; + const format = schema.format; + + if ( + format === "textarea" || + format === "rich-text" || + format === "rich-text-inline" || + format === "markdown" + ) { + return ( +
+ + {schema.description && ( +

{schema.description}

+ )} +