Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mesh/src/api/routes/org-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
90 changes: 90 additions & 0 deletions apps/mesh/src/api/routes/vm-file.ts
Original file line number Diff line number Diff line change
@@ -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<Env>, 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<typeof requireOrganization>;
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<Env>();

app.post("/write", (c) => proxy(c, "/_decopilot_vm/write"));
app.post("/read", (c) => proxy(c, "/_decopilot_vm/read"));

return app;
};
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -114,10 +114,15 @@ export function useMultiPartDecisionForm<TPart, TValues extends FieldValues>(
})();
};

// 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 = () => {
Expand All @@ -130,7 +135,7 @@ export function useMultiPartDecisionForm<TPart, TValues extends FieldValues>(
return;
}
setPendingFlush(false);
flush();
flushRef.current();
} else {
const nextKey = findNextUnansweredKey(
parts,
Expand All @@ -145,10 +150,9 @@ export function useMultiPartDecisionForm<TPart, TValues extends FieldValues>(

// 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;
Expand All @@ -158,8 +162,8 @@ export function useMultiPartDecisionForm<TPart, TValues extends FieldValues>(
return;
}
setPendingFlush(false);
flush();
}, [pendingFlush, isStreaming, allAnswered, flush]);
flushRef.current();
}, [pendingFlush, isStreaming, allAnswered]);

return {
form,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).__resolveType === "string"
? ((value as Record<string, unknown>).__resolveType as string)
: (refs[0]?.resolveType ?? "");

const handleRefChange = (rt: string) => {
onChange({ __resolveType: rt });
};

return (
<div className="space-y-1.5">
<Label htmlFor={path}>{label}</Label>
<Select value={currentRt} onValueChange={handleRefChange}>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{refs.map((ref) => (
<SelectItem key={ref.resolveType} value={ref.resolveType}>
{ref.title}
</SelectItem>
))}
</SelectContent>
</Select>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
);
}

// ── Fallback: render a basic text input for unresolved anyOf fields ──
return (
<div className="space-y-1.5">
<Label htmlFor={path}>{label}</Label>
<input
id={path}
type="text"
value={value != null ? String(value) : ""}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-1.5 text-sm"
placeholder={schema.description ?? ""}
/>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{label}</span>
<Button type="button" variant="outline" size="sm" onClick={addItem}>
<Plus size={14} />
Add
</Button>
</div>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
{items.map((item, i) => (
<div key={`${path}.${i}`} className="border rounded-md p-3 relative">
<Button
type="button"
variant="ghost"
size="sm"
className="absolute top-1 right-1 h-7 w-7 p-0"
onClick={() => removeItem(i)}
>
<Trash01 size={14} />
</Button>
{itemSchema?.type === "object" && itemSchema.properties ? (
<SchemaForm
schema={itemSchema}
value={item}
onChange={(val) => 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}
</div>
))}
{items.length === 0 && (
<p className="text-xs text-muted-foreground py-2">No items yet.</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center justify-between gap-2 py-1">
<div>
<Label htmlFor={path}>{label}</Label>
{schema.description && (
<p className="text-xs text-muted-foreground">{schema.description}</p>
)}
</div>
<Switch
id={path}
checked={checked}
onCheckedChange={(v) => onChange(v)}
/>
</div>
);
}
Loading
Loading