@hosty-sdk/app 0.27.24 → 0.29.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/README.md CHANGED
@@ -544,3 +544,17 @@ Keep launch/auth exports for older hosts and native transport. Native clients se
544
544
  navigation/launcher and disabled tools until they provide a panel host. `panelLayout="none"` is a
545
545
  rendering choice, not a disable policy: explicitly disable local tools or register a renderer.
546
546
  See [UI composition and browser acceptance](../../docs/features/sdk-ui-composition/feature.md).
547
+
548
+ ## Child Core sandbox controls
549
+
550
+ `@hosty-sdk/app/sandbox/react` exports `CoreEnvironmentPanel` for operator-facing child Core
551
+ management. Supply an authenticated `EnvironmentRequest` transport, a tab-local replay storage key
552
+ and optionally a workspace with its active worktrees. Shell and Workspaces use this shared panel
553
+ for create/start, current/source Core selection, normal test-account setup, app status, preview and
554
+ confirmed deletion. The component does not grant authority: Core independently checks the optional
555
+ `core.environments.inspect/operate` grants, administrator identity and workspace source access.
556
+
557
+ `@hosty-sdk/app/sandbox/server` provides the server-side clients for independent app sandboxes and
558
+ child Core environments. Caller-owned `core.environments.read/manage` authority remains distinct
559
+ from the operator panel's cross-caller permissions. See the
560
+ [sandbox contract](../../docs/features/app-sandbox-runtimes/feature.md) for lifecycle and cleanup rules.
package/dist/index.d.ts CHANGED
@@ -206,3 +206,8 @@ export declare function decideRecoveryAction(input: {
206
206
  }): RecoveryAction;
207
207
  /** Paint-time navigation policy. Kept in the pure entry point for Node and edge consumers. */
208
208
  export declare function chromeBootstrapScript(defaults?: import("./ui-types").UiChromeOverrides): string;
209
+ /** Execution scope is independent of the principal's role. Never infer it from an app ID. */
210
+ import type { SandboxTarget, SandboxIdentityContext } from "./sandbox-identity.js";
211
+ export type { SandboxTarget, SandboxIdentityContext } from "./sandbox-identity.js";
212
+ export declare function readSandboxTarget(environment: Record<string, string | undefined>): SandboxTarget | null;
213
+ export declare function readSandboxIdentity(payload: Record<string, unknown>, expected: SandboxTarget | null): SandboxIdentityContext | null;
package/dist/index.js CHANGED
@@ -269,3 +269,24 @@ export function chromeBootstrapScript(defaults = {}) {
269
269
  throw new Error("Invalid navigation default.");
270
270
  return `(()=>{try{const d=${JSON.stringify({ navigation })},p=new URLSearchParams(location.search),h=new URLSearchParams();for(const k of ["hosty_navigation","hosty_tools","hosty_tool_launcher","hosty_launch"])for(const v of p.getAll(k))h.append(k,v);document.documentElement.setAttribute('data-hosty-chrome-hints',h.toString());const one=k=>p.getAll(k).length===1?p.get(k):null,n=one('hosty_navigation'),l=one('hosty_launch');document.documentElement.setAttribute('data-hosty-navigation',n==='show'||n==='hide'?n:d.navigation??(l==='native'||l==='embedded'?'hide':'show'));}catch{}})();`;
271
271
  }
272
+ export function readSandboxTarget(environment) {
273
+ const fields = [environment.HOSTY_SANDBOX_INSTANCE_ID, environment.HOSTY_SANDBOX_GENERATION, environment.HOSTY_SANDBOX_DATA_REVISION];
274
+ if (!environment.HOSTY_SANDBOX_PROTOCOL && fields.every(value => !value))
275
+ return null;
276
+ if (environment.HOSTY_SANDBOX_PROTOCOL !== "hosty-sandbox-v1" || fields.some(value => !/^[a-f0-9]{32}$/.test(value ?? "")))
277
+ throw new Error("The sandbox execution target is missing or unsupported.");
278
+ return { runtimeInstanceId: fields[0], generation: fields[1], dataRevision: fields[2] };
279
+ }
280
+ export function readSandboxIdentity(payload, expected) {
281
+ if (!expected) {
282
+ if (payload.identityRealm === "sandbox" || payload.runtimeInstanceId != null || payload.generation != null || payload.dataRevision != null)
283
+ throw new Error("An installed app cannot accept a sandbox identity.");
284
+ return null;
285
+ }
286
+ if (payload.runtimeInstanceId !== expected.runtimeInstanceId || payload.generation !== expected.generation || payload.dataRevision !== expected.dataRevision
287
+ || (payload.identityRealm !== "sandbox" && payload.identityRealm !== "core") || typeof payload.appRole !== "string"
288
+ || !Array.isArray(payload.permissions) || !payload.permissions.every(value => typeof value === "string")
289
+ || (payload.identityRealm === "sandbox" && payload.hostRole != null))
290
+ throw new Error("The identity belongs to a different or unsupported sandbox target.");
291
+ return { ...expected, identityRealm: payload.identityRealm, appRole: payload.appRole, permissions: payload.permissions };
292
+ }
@@ -0,0 +1,10 @@
1
+ export interface SandboxTarget {
2
+ runtimeInstanceId: string;
3
+ generation: string;
4
+ dataRevision: string;
5
+ }
6
+ export interface SandboxIdentityContext extends SandboxTarget {
7
+ identityRealm: "sandbox" | "core";
8
+ appRole: string;
9
+ permissions: string[];
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ export type EnvironmentRequest = <T>(path: string, body?: unknown, signal?: AbortSignal) => Promise<T>;
2
+ export interface EnvironmentWorkspace {
3
+ id: string;
4
+ worktrees: {
5
+ id: string;
6
+ label: string;
7
+ state: string;
8
+ }[];
9
+ }
10
+ export interface EnvironmentApps {
11
+ state: string;
12
+ apps: {
13
+ id: string;
14
+ displayName: string;
15
+ version?: string;
16
+ runtimeState: string;
17
+ health?: string;
18
+ previewUrl?: string;
19
+ }[];
20
+ error?: string;
21
+ }
22
+ /** Shared operator panel; each host supplies its authenticated same-origin Core transport. */
23
+ export declare function CoreEnvironmentPanel({ request, storageKey, workspace, workspaceHref }: {
24
+ request: EnvironmentRequest;
25
+ storageKey: string;
26
+ workspace?: EnvironmentWorkspace;
27
+ workspaceHref?: (id: string) => string;
28
+ }): import("react").JSX.Element;
@@ -0,0 +1,208 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ const button = "inline-flex items-center justify-center gap-2 rounded-md border bg-background px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed";
5
+ const field = "w-full rounded-md border bg-background px-3 py-2 text-sm";
6
+ function message(error) { return error instanceof Error ? error.message : "Sandbox request failed."; }
7
+ function link(value) {
8
+ try {
9
+ const url = new URL(value ?? "");
10
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password ? url.href : undefined;
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ /** Shared operator panel; each host supplies its authenticated same-origin Core transport. */
17
+ export function CoreEnvironmentPanel({ request, storageKey, workspace, workspaceHref }) {
18
+ const [environments, setEnvironments] = useState([]);
19
+ const [apps, setApps] = useState({});
20
+ const [loading, setLoading] = useState(true);
21
+ const [error, setError] = useState("");
22
+ const [readError, setReadError] = useState("");
23
+ const [notice, setNotice] = useState("");
24
+ const [busy, setBusy] = useState(false);
25
+ const [pending, setPending] = useState(null);
26
+ const pendingRef = useRef(null);
27
+ const active = useRef(true);
28
+ const refreshSequence = useRef(0);
29
+ const [removing, setRemoving] = useState(null);
30
+ const [creating, setCreating] = useState(false);
31
+ const savePending = useCallback((value) => {
32
+ // Do not send a mutation if its replay identity cannot be persisted.
33
+ if (value)
34
+ sessionStorage.setItem(storageKey, JSON.stringify(value));
35
+ else
36
+ sessionStorage.removeItem(storageKey);
37
+ pendingRef.current = value;
38
+ if (active.current)
39
+ setPending(value);
40
+ }, [storageKey]);
41
+ const refresh = useCallback(async (signal) => {
42
+ const sequence = ++refreshSequence.current;
43
+ try {
44
+ const result = await request("", undefined, signal);
45
+ if (signal?.aborted || !active.current || sequence !== refreshSequence.current)
46
+ return;
47
+ const selected = result.environments.filter(e => !workspace || e.context?.workspaceId === workspace.id);
48
+ setEnvironments(selected.filter(e => e.state !== "finished"));
49
+ setLoading(false);
50
+ setReadError("");
51
+ const saved = pendingRef.current;
52
+ if (saved?.id) {
53
+ const item = selected.find(e => e.id === saved.id);
54
+ const operation = item?.operations.find(o => o.id.replaceAll("-", "") === String(saved.body.requestId).replaceAll("-", ""));
55
+ if (operation && ["succeeded", "failed"].includes(operation.state)) {
56
+ const superseded = operation !== item?.operations.at(-1);
57
+ if (operation.state === "succeeded" || saved.kind !== "finish" || superseded)
58
+ savePending(null);
59
+ setError(operation.state === "failed" ? item?.error ?? "Sandbox operation failed." : "");
60
+ if (item?.state === "finished")
61
+ setNotice("Sandbox deleted. Workspace source is preserved.");
62
+ else if (superseded)
63
+ setNotice("A newer operation replaced the previous request. Review the current sandbox status.");
64
+ }
65
+ }
66
+ const results = await Promise.all(selected.filter(e => e.state === "running").map(async (e) => {
67
+ try {
68
+ return [e.id, await request(`/${e.id}/apps`, undefined, signal)];
69
+ }
70
+ catch (cause) {
71
+ return [e.id, { state: "unavailable", apps: [], error: message(cause) }];
72
+ }
73
+ }));
74
+ if (!signal?.aborted && active.current && sequence === refreshSequence.current)
75
+ setApps(Object.fromEntries(results));
76
+ }
77
+ catch (cause) {
78
+ if (!signal?.aborted && active.current && sequence === refreshSequence.current) {
79
+ setEnvironments([]);
80
+ setApps({});
81
+ setLoading(false);
82
+ setReadError(message(cause));
83
+ }
84
+ }
85
+ }, [request, workspace, savePending]);
86
+ useEffect(() => {
87
+ active.current = true;
88
+ const controller = new AbortController();
89
+ try {
90
+ const saved = JSON.parse(sessionStorage.getItem(storageKey) ?? "null");
91
+ if (saved && /^\/(?:from-workspace|[a-f0-9]{32}\/operations\/(?:start|stop|restart|finish))$/.test(saved.path)
92
+ && typeof saved.body?.requestId === "string") {
93
+ pendingRef.current = saved;
94
+ setPending(saved);
95
+ setNotice("A previous request needs confirmation. Refresh or retry the same request.");
96
+ }
97
+ }
98
+ catch {
99
+ setError("Cannot read the saved sandbox request.");
100
+ }
101
+ let polling = false;
102
+ async function poll() {
103
+ if (polling)
104
+ return;
105
+ polling = true;
106
+ try {
107
+ await refresh(controller.signal);
108
+ }
109
+ finally {
110
+ polling = false;
111
+ }
112
+ }
113
+ void poll();
114
+ const timer = setInterval(() => { if (document.visibilityState === "visible")
115
+ void poll(); }, 5000);
116
+ return () => { active.current = false; controller.abort(); clearInterval(timer); };
117
+ }, [refresh, storageKey]);
118
+ async function run(value) {
119
+ setBusy(true);
120
+ setError("");
121
+ try {
122
+ savePending(value);
123
+ const result = await request(value.path, value.body);
124
+ if (value.kind !== "finish" || result.state === "finished")
125
+ savePending(null);
126
+ if (result.error)
127
+ throw new Error(result.error);
128
+ if (result.state === "finished")
129
+ setNotice("Sandbox deleted. Workspace source is preserved.");
130
+ else
131
+ setNotice("");
132
+ await refresh();
133
+ return result;
134
+ }
135
+ catch (cause) {
136
+ const status = cause.status;
137
+ if (status && status >= 400 && status < 500)
138
+ savePending(null);
139
+ if (active.current)
140
+ setError(message(cause));
141
+ return null;
142
+ }
143
+ finally {
144
+ if (active.current)
145
+ setBusy(false);
146
+ }
147
+ }
148
+ async function command(item, kind) {
149
+ return run({ path: `/${item.id}/operations/${kind}`, body: { requestId: crypto.randomUUID(), expectedRevision: item.revision }, id: item.id, kind });
150
+ }
151
+ async function create(body) {
152
+ const created = await run({ path: "/from-workspace", body: { ...body, requestId: crypto.randomUUID() }, kind: "create" });
153
+ if (created) {
154
+ if (active.current) {
155
+ setCreating(false);
156
+ await command(created, "start");
157
+ }
158
+ }
159
+ }
160
+ const disabled = busy || pending !== null;
161
+ return _jsxs("section", { className: "space-y-4 rounded-lg border bg-card p-4", "aria-label": "Sandbox environments", children: [_jsxs("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h3", { className: "font-semibold", children: "Sandbox environments" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Separate Core instances with their own apps, users and temporary data." })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { className: button, onClick: () => { setError(""); void refresh(); }, children: "Refresh sandboxes" }), workspace && _jsx("button", { className: button, disabled: disabled || !workspace.worktrees.some(t => t.state === "active"), onClick: () => setCreating(!creating), children: "Create sandbox" })] })] }), creating && workspace && _jsx(CreateEnvironment, { workspace: workspace, disabled: disabled, onCreate: create, onCancel: () => setCreating(false) }), notice && _jsx("p", { role: "status", className: "text-sm text-muted-foreground", children: notice }), (error || readError) && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: error || readError }), pending && _jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsx("span", { children: busy ? `Sandbox ${pending.kind} is in progress…` : "The request outcome is not confirmed." }), _jsx("button", { className: button, disabled: busy, onClick: () => void run(pending), children: "Retry same request" })] }), loading ? _jsx("p", { role: "status", className: "text-sm text-muted-foreground", children: "Loading sandboxes\u2026" }) : !environments.length && !error && !readError && _jsx("p", { className: "text-sm text-muted-foreground", children: "No active sandbox environments." }), environments.map(item => _jsxs("article", { className: "space-y-3 rounded-md border p-3", "aria-label": `Sandbox ${item.label}`, children: [_jsxs("header", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsxs("h4", { className: "font-medium", children: [item.label, " ", _jsx("span", { className: "ml-2 rounded border px-2 py-0.5 text-xs font-normal", children: item.state })] }), _jsxs("p", { className: "mt-1 text-xs text-muted-foreground", children: [item.mode === "source" ? "Core built from workspace source" : item.mode === "release" ? "Installed Core release" : "Current Core build", item.context?.workspaceId && workspaceHref && _jsxs(_Fragment, { children: [" \u00B7 ", _jsx("a", { className: "underline", href: workspaceHref(item.context.workspaceId), children: "Open workspace" })] })] })] }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [item.state === "running" && link(item.previewUrl) && _jsx("a", { className: button, href: item.previewUrl + "/login", target: "_blank", rel: "noreferrer", children: "Open Core" }), _jsx("button", { className: button, disabled: disabled || ["building", "starting", "stopping", "finishing", "cleanup-failed"].includes(item.state), onClick: () => void command(item, item.state === "running" ? "stop" : "start"), children: item.state === "running" ? "Stop" : "Start" }), _jsx("button", { className: button + " text-destructive", disabled: disabled || item.state === "finishing", onClick: () => setRemoving(item.id), children: item.state === "cleanup-failed" ? "Retry deletion" : "Delete sandbox" })] })] }), item.limits && _jsxs("p", { className: "text-xs text-muted-foreground", children: ["Reserved: ", (item.limits.memoryBytes / 1073741824).toFixed(1), " GiB container memory \u00B7 ", item.limits.cpus, " CPUs \u00B7 ", item.limits.pids, " PIDs \u00B7 ", (item.limits.diskBytes / 1073741824).toFixed(1), " GiB soft disk limit. Released after deletion."] }), item.usage && _jsx("p", { className: "text-xs text-muted-foreground", children: item.usage.state === "unknown" ? "Resource usage unavailable; the reservation remains active." : `Observed: ${(item.usage.containerMemoryBytes / 1048576).toFixed(0)} MiB containers · ${(item.usage.coreMemoryBytes / 1048576).toFixed(0)} MiB Core process · ${(item.usage.ownedDiskBytes / 1073741824).toFixed(2)} GiB owned data.` }), item.error && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: item.error }), item.state === "running" && _jsxs(_Fragment, { children: [_jsx(EnvironmentApplications, { value: apps[item.id] }), apps[item.id] && ["setup-required", "sign-in-required"].includes(apps[item.id].state) && _jsx(SetupAccount, { request: request, id: item.id, disabled: disabled, onDone: () => refresh() })] }), removing === item.id && _jsxs("div", { role: "alertdialog", "aria-label": `Delete ${item.label}`, className: "space-y-3 rounded-md border border-destructive p-3", children: [_jsx("p", { className: "text-sm", children: "Permanently delete this sandbox, all its apps, test users, data and caches? Workspace source and shared folders are preserved." }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { className: button + " text-destructive", disabled: disabled, onClick: () => { setRemoving(null); void command(item, "finish"); }, children: "Delete permanently" }), _jsx("button", { className: button, onClick: () => setRemoving(null), children: "Cancel" })] })] })] }, item.id))] });
162
+ }
163
+ function EnvironmentApplications({ value }) {
164
+ if (!value)
165
+ return _jsx("p", { role: "status", className: "text-sm text-muted-foreground", children: "Reading application status\u2026" });
166
+ if (value.state !== "ready")
167
+ return _jsx("p", { role: "status", className: "text-sm text-muted-foreground", children: value.error ?? (value.state === "setup-required" ? "Set up a test administrator for this fresh Core." : "Application status is unavailable.") });
168
+ if (!value.apps.length)
169
+ return _jsx("p", { className: "text-sm text-muted-foreground", children: "No apps installed in this sandbox yet." });
170
+ return _jsx("ul", { className: "divide-y", "aria-label": "Sandbox applications", children: value.apps.map(app => _jsxs("li", { className: "flex flex-wrap items-center justify-between gap-3 py-2 text-sm", children: [_jsxs("div", { children: [_jsx("span", { className: "font-medium", children: app.displayName }), _jsxs("span", { className: "ml-2 text-muted-foreground", children: [app.runtimeState, " \u00B7 ", app.health ?? "Health unknown"] })] }), app.runtimeState === "running" && link(app.previewUrl) && _jsxs("a", { className: button, href: app.previewUrl, target: "_blank", rel: "noreferrer", children: ["Open preview", _jsxs("span", { className: "sr-only", children: [" for ", app.displayName] })] })] }, app.id)) });
171
+ }
172
+ function CreateEnvironment({ workspace, disabled, onCreate, onCancel }) {
173
+ const trees = workspace.worktrees.filter(t => t.state === "active");
174
+ const [selected, setSelected] = useState(trees.slice(0, 16).map(t => t.id));
175
+ const [source, setSource] = useState(false);
176
+ function submit(event) {
177
+ event.preventDefault();
178
+ const form = new FormData(event.currentTarget);
179
+ void onCreate({ label: form.get("label"), workspaceId: workspace.id, worktreeIds: selected,
180
+ ...(source ? { coreWorktreeId: form.get("coreWorktreeId"), coreProject: form.get("coreProject") } : {}) });
181
+ }
182
+ return _jsxs("form", { onSubmit: submit, className: "space-y-3 rounded-md border bg-muted/30 p-3", "aria-label": "Create workspace sandbox", children: [_jsxs("label", { className: "block space-y-1 text-sm", children: ["Sandbox name", _jsx("input", { className: field, name: "label", required: true, maxLength: 120, defaultValue: "Workspace sandbox" })] }), _jsxs("fieldset", { className: "space-y-2", children: [_jsx("legend", { className: "text-sm font-medium", children: "Attached worktrees" }), trees.map(t => _jsxs("label", { className: "flex gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: selected.includes(t.id), onChange: e => setSelected(old => e.target.checked ? [...old, t.id] : old.filter(id => id !== t.id)) }), t.label] }, t.id))] }), _jsxs("label", { className: "flex gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: source, onChange: e => setSource(e.target.checked) }), "Build Core from a worktree"] }), source && _jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsxs("label", { className: "space-y-1 text-sm", children: ["Core worktree", _jsx("select", { className: field, name: "coreWorktreeId", required: true, children: trees.filter(t => selected.includes(t.id)).map(t => _jsx("option", { value: t.id, children: t.label }, t.id)) })] }), _jsxs("label", { className: "space-y-1 text-sm", children: ["Core project, relative to worktree", _jsx("input", { className: field, name: "coreProject", required: true, defaultValue: "apps/core/src/Haas.Hosty.Core/Haas.Hosty.Core.csproj" })] })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Starts a fresh Core with separate users and data. Attached worktrees stay available until the sandbox is deleted." }), _jsxs("div", { className: "flex gap-2", children: [_jsx("button", { className: button, type: "submit", disabled: disabled || selected.length === 0 || selected.length > 16, children: "Create and start" }), _jsx("button", { className: button, type: "button", onClick: onCancel, children: "Cancel" })] })] });
183
+ }
184
+ function SetupAccount({ request, id, disabled, onDone }) {
185
+ const [error, setError] = useState("");
186
+ const [busy, setBusy] = useState(false);
187
+ async function submit(event) {
188
+ event.preventDefault();
189
+ const form = event.currentTarget;
190
+ const data = new FormData(form);
191
+ setBusy(true);
192
+ setError("");
193
+ try {
194
+ const result = await request(`/${id}/setup`, { email: data.get("email"), displayName: data.get("displayName"), password: data.get("password") });
195
+ if (result.status !== 200)
196
+ throw new Error(result.body?.message ?? "Sandbox account setup failed.");
197
+ await onDone();
198
+ }
199
+ catch (cause) {
200
+ setError(message(cause));
201
+ }
202
+ finally {
203
+ form.elements.namedItem("password").value = "";
204
+ setBusy(false);
205
+ }
206
+ }
207
+ return _jsxs("form", { onSubmit: submit, "aria-label": "Sandbox administrator", className: "space-y-3 rounded-md border p-3", children: [_jsx("p", { className: "text-sm", children: "Use a separate sandbox administrator. For an existing Core, enter its current account. Parent credentials are not copied." }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-3", children: [_jsxs("label", { className: "text-sm", children: ["Display name", _jsx("input", { className: field, name: "displayName", required: true, autoComplete: "off" })] }), _jsxs("label", { className: "text-sm", children: ["Test email", _jsx("input", { className: field, type: "email", name: "email", required: true, autoComplete: "off" })] }), _jsxs("label", { className: "text-sm", children: ["Test password", _jsx("input", { className: field, type: "password", name: "password", minLength: 12, required: true, autoComplete: "new-password" })] })] }), error && _jsx("p", { role: "alert", className: "text-sm text-destructive", children: error }), _jsx("button", { className: button, disabled: disabled || busy, children: busy ? "Connecting…" : "Connect test account" })] });
208
+ }
@@ -0,0 +1,180 @@
1
+ import type { ProviderServerOptions } from "./providers-server.js";
2
+ export type SandboxSource = {
3
+ kind: "worktree";
4
+ worktreeId: string;
5
+ } | {
6
+ kind: "appArtifact";
7
+ artifactDigest?: string;
8
+ };
9
+ export interface SandboxLimits {
10
+ memoryBytes?: number;
11
+ cpus?: number;
12
+ pids?: number;
13
+ dataBytes?: number;
14
+ cacheBytes?: number;
15
+ startupSeconds?: number;
16
+ }
17
+ export interface SandboxContext {
18
+ workspaceId?: string;
19
+ sessionId?: string;
20
+ jobId?: string;
21
+ }
22
+ export interface SandboxPlanInput {
23
+ appId: string;
24
+ profile: string;
25
+ source: SandboxSource;
26
+ limits?: SandboxLimits;
27
+ context?: SandboxContext;
28
+ }
29
+ export interface SandboxPlan {
30
+ id: string;
31
+ digest: string;
32
+ inputDigest: string;
33
+ request: SandboxPlanInput;
34
+ blockers: string[];
35
+ expiresAt: string;
36
+ }
37
+ export interface SandboxOperation {
38
+ id: string;
39
+ kind: string;
40
+ state: string;
41
+ errorCode?: string;
42
+ message?: string;
43
+ }
44
+ export interface SandboxUser {
45
+ id: string;
46
+ fixtureKey: string;
47
+ displayName: string;
48
+ role: string;
49
+ permissions: string[];
50
+ enabled: boolean;
51
+ revision: number;
52
+ }
53
+ export interface SandboxInstance {
54
+ id: string;
55
+ appId: string;
56
+ source: SandboxSource;
57
+ context?: SandboxContext;
58
+ revision: number;
59
+ generation: string;
60
+ dataRevision: string;
61
+ state: string;
62
+ previewUrl?: string;
63
+ errorCode?: string;
64
+ operations: SandboxOperation[];
65
+ users: SandboxUser[];
66
+ viewers?: string[];
67
+ endpoints?: {
68
+ key: string;
69
+ url: string;
70
+ }[];
71
+ metrics?: {
72
+ at: string;
73
+ memoryBytes: number | null;
74
+ sampledPeakMemoryBytes: number | null;
75
+ services: {
76
+ service: string;
77
+ memoryBytes: number | null;
78
+ cpuPercent: number | null;
79
+ }[];
80
+ };
81
+ storage?: {
82
+ at: string;
83
+ dataBytes: number;
84
+ seedBytes: number;
85
+ cacheBytes: number;
86
+ evidenceBytes: number;
87
+ totalBytes: number;
88
+ warning: boolean;
89
+ errorCode?: string;
90
+ };
91
+ }
92
+ export interface SandboxDiagnostics {
93
+ instanceId: string;
94
+ state: string;
95
+ errorCode?: string;
96
+ services: {
97
+ service: string;
98
+ state: string;
99
+ text: string;
100
+ }[];
101
+ }
102
+ export declare const sandboxActions: readonly ["sandbox-logs", "sandbox-plan", "sandbox-prepare", "sandbox-list", "sandbox-status", "sandbox-users", "sandbox-seed", "sandbox-start", "sandbox-restart", "sandbox-stop", "sandbox-reset", "sandbox-dispose", "sandbox-browser", "sandbox-export", "sandbox-user-delete", "sandbox-viewers", "sandbox-evidence-clear", "sandbox-preview", "sandbox-browser-cancel"];
103
+ export type SandboxAction = typeof sandboxActions[number];
104
+ export declare class SandboxError extends Error {
105
+ readonly code: string;
106
+ readonly status: number | null;
107
+ constructor(code: string, message: string, status: number | null);
108
+ }
109
+ /** Server-only transport. The authority callback can supply an interactive grant or an explicitly
110
+ * approved BackgroundGrantClient.token(); neither workspaces nor assistant roles are required. */
111
+ export declare class SandboxClient {
112
+ private readonly authority;
113
+ private readonly origin;
114
+ private readonly appId;
115
+ private readonly service;
116
+ constructor(authority: () => Promise<string>, options?: ProviderServerOptions);
117
+ plan(input: SandboxPlanInput): Promise<SandboxPlan>;
118
+ prepare(input: {
119
+ requestId: string;
120
+ planId: string;
121
+ digest: string;
122
+ }): Promise<SandboxInstance>;
123
+ list(): Promise<{
124
+ instances: SandboxInstance[];
125
+ }>;
126
+ status(runtimeInstanceId: string): Promise<SandboxInstance>;
127
+ command(runtimeInstanceId: string, action: "start" | "restart" | "stop" | "reset" | "dispose", input: {
128
+ requestId: string;
129
+ expectedRevision: number;
130
+ seedRevision?: string;
131
+ }): Promise<SandboxInstance>;
132
+ call<T = unknown>(action: SandboxAction, input: Record<string, unknown>): Promise<T>;
133
+ }
134
+ export declare const coreEnvironmentActions: readonly ["environment-create", "environment-list", "environment-status", "environment-start", "environment-stop", "environment-restart", "environment-finish", "environment-setup", "environment-api"];
135
+ export type CoreEnvironmentAction = typeof coreEnvironmentActions[number];
136
+ export interface CoreEnvironment {
137
+ id: string;
138
+ label: string;
139
+ mode: string;
140
+ projectPath?: string;
141
+ context?: SandboxContext;
142
+ state: string;
143
+ revision: number;
144
+ port: number;
145
+ dataRoot?: string;
146
+ previewUrl: string;
147
+ process?: {
148
+ pid: number;
149
+ startedAt: string;
150
+ };
151
+ error?: string;
152
+ operations: SandboxOperation[];
153
+ limits?: {
154
+ memoryBytes: number;
155
+ cpus: number;
156
+ pids: number;
157
+ diskBytes: number;
158
+ applicationMemoryBytes: number;
159
+ applicationCpus: number;
160
+ applicationPids: number;
161
+ };
162
+ usage?: {
163
+ ownedDiskBytes: number;
164
+ containerMemoryBytes: number;
165
+ coreMemoryBytes: number;
166
+ state: string;
167
+ observedAt: string;
168
+ error?: string;
169
+ };
170
+ }
171
+ /** Full child Core transport. Requires core.environments permissions and live administrator authority.
172
+ * Setup uses ordinary password authentication. Parent credentials are never forwarded to the child. */
173
+ export declare class CoreEnvironmentClient {
174
+ private readonly authority;
175
+ private readonly origin;
176
+ private readonly appId;
177
+ private readonly service;
178
+ constructor(authority: () => Promise<string>, options?: ProviderServerOptions);
179
+ call<T = unknown>(action: CoreEnvironmentAction, input: Record<string, unknown>): Promise<T>;
180
+ }
@@ -0,0 +1,147 @@
1
+ export const sandboxActions = ["sandbox-logs", "sandbox-plan", "sandbox-prepare", "sandbox-list", "sandbox-status", "sandbox-users", "sandbox-seed",
2
+ "sandbox-start", "sandbox-restart", "sandbox-stop", "sandbox-reset", "sandbox-dispose", "sandbox-browser", "sandbox-export", "sandbox-user-delete", "sandbox-viewers", "sandbox-evidence-clear", "sandbox-preview", "sandbox-browser-cancel"];
3
+ export class SandboxError extends Error {
4
+ code;
5
+ status;
6
+ constructor(code, message, status) {
7
+ super(message);
8
+ this.code = code;
9
+ this.status = status;
10
+ this.name = "SandboxError";
11
+ }
12
+ }
13
+ /** Server-only transport. The authority callback can supply an interactive grant or an explicitly
14
+ * approved BackgroundGrantClient.token(); neither workspaces nor assistant roles are required. */
15
+ export class SandboxClient {
16
+ authority;
17
+ origin;
18
+ appId;
19
+ service;
20
+ constructor(authority, options = {}) {
21
+ this.authority = authority;
22
+ this.origin = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
23
+ this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
24
+ this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
25
+ if (!this.appId || !this.service || !["http:", "https:"].includes(this.origin.protocol) || this.origin.username || this.origin.password
26
+ || this.origin.pathname !== "/" || this.origin.search || this.origin.hash)
27
+ throw new Error("A valid Core origin and calling app service identity are required.");
28
+ }
29
+ plan(input) { return this.call("sandbox-plan", input); }
30
+ prepare(input) { return this.call("sandbox-prepare", input); }
31
+ list() { return this.call("sandbox-list", {}); }
32
+ status(runtimeInstanceId) { return this.call("sandbox-status", { runtimeInstanceId }); }
33
+ command(runtimeInstanceId, action, input) {
34
+ return this.call(`sandbox-${action}`, { runtimeInstanceId, ...input });
35
+ }
36
+ async call(action, input) {
37
+ if (!sandboxActions.includes(action))
38
+ throw new SandboxError("sandbox_operation_invalid", "Unknown sandbox operation.", 400);
39
+ const { runtimeInstanceId, ...body } = input;
40
+ const jobId = body.jobId;
41
+ const root = `/api/internal/apps/${encodeURIComponent(this.appId)}/sandbox-instances`;
42
+ const read = action === "sandbox-logs" || action === "sandbox-list" || action === "sandbox-status" || action === "sandbox-export" || action === "sandbox-preview";
43
+ let suffix = "";
44
+ if (action === "sandbox-plan")
45
+ suffix = "/plans";
46
+ else if (action !== "sandbox-list" && action !== "sandbox-prepare") {
47
+ if (typeof runtimeInstanceId !== "string" || !/^[a-f0-9]{32}$/.test(runtimeInstanceId))
48
+ throw new SandboxError("sandbox_id_invalid", "Use the Core-issued runtime instance ID.", 400);
49
+ suffix = `/${runtimeInstanceId}`;
50
+ if (action === "sandbox-logs")
51
+ suffix += "/diagnostics";
52
+ else if (action === "sandbox-users")
53
+ suffix += "/users";
54
+ else if (action === "sandbox-user-delete")
55
+ suffix += "/users/delete";
56
+ else if (action === "sandbox-viewers")
57
+ suffix += "/viewers";
58
+ else if (action === "sandbox-evidence-clear")
59
+ suffix += "/evidence/clear";
60
+ else if (action === "sandbox-seed")
61
+ suffix += "/seeds";
62
+ else if (action === "sandbox-browser")
63
+ suffix += "/browser-runs";
64
+ else if (action === "sandbox-browser-cancel")
65
+ suffix += "/browser-runs/cancel";
66
+ else if (action === "sandbox-preview")
67
+ suffix += "/preview" + (typeof body.userId === "string" ? "?userId=" + encodeURIComponent(body.userId) : "");
68
+ else if (action === "sandbox-export") {
69
+ if (typeof jobId !== "string" || !/^[a-f0-9]{32}$/.test(jobId))
70
+ throw new SandboxError("sandbox_job_invalid", "Select a recorded browser run.", 400);
71
+ suffix += `/browser-runs/${jobId}`;
72
+ }
73
+ else if (!read)
74
+ suffix += `/operations/${action.slice("sandbox-".length)}`;
75
+ }
76
+ if (!read && action !== "sandbox-plan" && (typeof body.requestId !== "string" || !/^[a-f0-9-]{32,36}$/i.test(body.requestId)))
77
+ throw new SandboxError("sandbox_request_required", "Persist a request UUID before sending a mutation. Replay the same UUID and arguments after an uncertain result.", 400);
78
+ const credential = await this.authority();
79
+ let response;
80
+ try {
81
+ response = await fetch(new URL(root + suffix, this.origin), { method: read ? "GET" : "POST", redirect: "error", cache: "no-store",
82
+ headers: { authorization: `Bearer ${this.service}`, "X-Hosty-User-Token": credential, "content-type": "application/json" },
83
+ body: read ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(action === "sandbox-browser" ? 330_000 : 180_000) });
84
+ }
85
+ catch {
86
+ throw new SandboxError("sandbox_outcome_unknown", "Core could not confirm the outcome. Read the operation or replay its original request UUID and arguments.", null);
87
+ }
88
+ const result = await response.json().catch(() => null);
89
+ if (!result)
90
+ throw new SandboxError("sandbox_outcome_unknown", "Core returned no readable outcome; keep the original operation identity.", response.status);
91
+ if (!response.ok)
92
+ throw new SandboxError(result.code ?? "sandbox_failed", result.message ?? "Sandbox request failed.", response.status);
93
+ return result;
94
+ }
95
+ }
96
+ export const coreEnvironmentActions = ["environment-create", "environment-list", "environment-status", "environment-start",
97
+ "environment-stop", "environment-restart", "environment-finish", "environment-setup", "environment-api"];
98
+ /** Full child Core transport. Requires core.environments permissions and live administrator authority.
99
+ * Setup uses ordinary password authentication. Parent credentials are never forwarded to the child. */
100
+ export class CoreEnvironmentClient {
101
+ authority;
102
+ origin;
103
+ appId;
104
+ service;
105
+ constructor(authority, options = {}) {
106
+ this.authority = authority;
107
+ this.origin = new URL(options.coreOrigin ?? process.env.HOSTY_CORE_ORIGIN ?? "http://127.0.0.1:7070");
108
+ this.appId = options.appId ?? process.env.HOSTY_APP_ID ?? "";
109
+ this.service = options.serviceToken ?? process.env.HOSTY_APP_SERVICE_TOKEN ?? "";
110
+ if (!this.appId || !this.service || !["http:", "https:"].includes(this.origin.protocol) || this.origin.username || this.origin.password
111
+ || this.origin.pathname !== "/" || this.origin.search || this.origin.hash)
112
+ throw new Error("Core origin and app service identity are required.");
113
+ }
114
+ async call(action, input) {
115
+ if (!coreEnvironmentActions.includes(action))
116
+ throw new SandboxError("environment_operation_invalid", "Unknown environment operation.", 400);
117
+ const { environmentId, ...body } = input;
118
+ const read = action === "environment-list" || action === "environment-status";
119
+ let suffix = "";
120
+ if (action !== "environment-list" && action !== "environment-create") {
121
+ if (typeof environmentId !== "string" || !/^[a-f0-9]{32}$/.test(environmentId))
122
+ throw new SandboxError("environment_id_invalid", "Select a Core-issued environment ID.", 400);
123
+ suffix = `/${environmentId}` + (action === "environment-status" ? "" : action === "environment-api" ? "/api"
124
+ : action === "environment-setup" ? "/setup" : `/operations/${action.slice("environment-".length)}`);
125
+ }
126
+ const replayable = !read && action !== "environment-api" && action !== "environment-setup";
127
+ if (replayable && (typeof body.requestId !== "string" || !/^[a-f0-9-]{32,36}$/i.test(body.requestId)))
128
+ throw new SandboxError("environment_request_required", "Persist a request UUID before sending lifecycle operations.", 400);
129
+ let response;
130
+ try {
131
+ response = await fetch(new URL(`/api/internal/apps/${encodeURIComponent(this.appId)}/core-environments${suffix}`, this.origin), {
132
+ method: read ? "GET" : "POST", redirect: "error", cache: "no-store",
133
+ headers: { authorization: `Bearer ${this.service}`, "X-Hosty-User-Token": await this.authority(), "content-type": "application/json" },
134
+ body: read ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(490_000),
135
+ });
136
+ }
137
+ catch {
138
+ throw new SandboxError("environment_outcome_unknown", "Core did not confirm the outcome. Observe state; replay lifecycle UUIDs unchanged. Child API mutations follow their own recovery contract.", null);
139
+ }
140
+ const result = await response.json().catch(() => null);
141
+ if (!result)
142
+ throw new SandboxError("environment_outcome_unknown", "Core returned no readable outcome.", response.status);
143
+ if (!response.ok)
144
+ throw new SandboxError(result.code ?? "environment_failed", result.message ?? "Environment request failed.", response.status);
145
+ return result;
146
+ }
147
+ }
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import "server-only";
2
+ import { type SandboxIdentityContext } from "./index.js";
2
3
  import { type AppContinuity } from "./continuity.js";
3
4
  import { type AppAuthProtocol } from "./app-code.js";
4
5
  export { isValidCodeVerifier } from "./app-code.js";
@@ -27,6 +28,8 @@ export interface HostIdentity {
27
28
  activeUntil?: string | null;
28
29
  activityRequired?: boolean;
29
30
  continuity?: AppContinuity;
31
+ /** Present for every identity (including real viewers) acting inside a sandbox. */
32
+ sandbox?: SandboxIdentityContext;
30
33
  }
31
34
  /** Structured detail for a failed resolution: the Core HTTP status (null when Core was
32
35
  * never reached) plus a machine-readable code and human-readable message. Codes pass
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Server slice: Next route-handler factories and Core revalidation. Never reaches a client
2
2
  // bundle — the service token lives here.
3
3
  import "server-only";
4
+ import { readSandboxTarget, readSandboxIdentity } from "./index.js";
4
5
  import { readAppContinuity } from "./continuity.js";
5
6
  import { createHostySessionResponse } from "./session-server.js";
6
7
  import { createHostyUiResponse } from "./ui-server.js";
@@ -67,6 +68,8 @@ export function readAppIdentityToken(headers, config) {
67
68
  return token;
68
69
  }
69
70
  }
71
+ if (process.env.HOSTY_SANDBOX_PROTOCOL)
72
+ return headers.get(HOSTY_APP_IDENTITY_HEADER)?.trim() || readCookie(headers.get("cookie"), config.identityCookieName) || null;
70
73
  return readCookie(headers.get("cookie"), config.identityCookieName) || headers.get(HOSTY_APP_IDENTITY_HEADER)?.trim() || null;
71
74
  }
72
75
  /** True when the effective request protocol is https (honouring the forwarded proto). */
@@ -119,7 +122,8 @@ export async function resolveAppSession(token, config) {
119
122
  };
120
123
  }
121
124
  const now = Date.now();
122
- const cached = revalidationCache.get(token);
125
+ // Synthetic subject changes and instance revocation are checked online on every request.
126
+ const cached = process.env.HOSTY_SANDBOX_PROTOCOL ? undefined : revalidationCache.get(token);
123
127
  if (cached) {
124
128
  if (now - cached.cachedAt < REVALIDATION_CACHE_TTL_MS && now < cached.expiresAtMs) {
125
129
  return { status: "active", identity: cached.identity };
@@ -211,6 +215,13 @@ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
211
215
  if (!payload || payload.active !== true || !userId) {
212
216
  return { status: "expired" };
213
217
  }
218
+ let sandbox;
219
+ try {
220
+ sandbox = readSandboxIdentity(payload, readSandboxTarget(process.env));
221
+ }
222
+ catch {
223
+ return { status: "forbidden", error: { status: 403, code: "sandbox_identity_mismatch", message: "The identity does not match this execution target." } };
224
+ }
214
225
  const hostRole = readString(payload.hostRole);
215
226
  const expiresAt = readString(payload.expiresAt);
216
227
  const identity = {
@@ -218,14 +229,15 @@ async function revalidateWithCore(token, coreOrigin, serviceToken, config) {
218
229
  email: readString(payload.email),
219
230
  displayName: readString(payload.displayName),
220
231
  hostRole,
221
- appRole: config.mapHostRole ? config.mapHostRole(hostRole) : hostRole,
232
+ appRole: sandbox?.identityRealm === "sandbox" ? sandbox.appRole : config.mapHostRole ? config.mapHostRole(hostRole) : hostRole,
222
233
  expiresAt,
223
234
  activeUntil: readString(payload.activeUntil),
224
235
  activityRequired: payload.activityRequired === true,
225
236
  continuity: readAppContinuity(payload.continuity),
237
+ ...(sandbox ? { sandbox } : {}),
226
238
  };
227
239
  const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
228
- if (Number.isFinite(expiresAtMs) && expiresAtMs > Date.now()) {
240
+ if (!sandbox && Number.isFinite(expiresAtMs) && expiresAtMs > Date.now()) {
229
241
  pruneRevalidationCache();
230
242
  revalidationCache.set(token, { identity, expiresAtMs, cachedAt: Date.now() });
231
243
  }
@@ -1,5 +1,6 @@
1
1
  import { type ProviderServerOptions } from "@hosty-sdk/app/providers/server";
2
2
  import type { AppSessionStatus, SessionRecoveryParams } from "./session-types.js";
3
+ import type { SandboxIdentityContext } from "./sandbox-identity.js";
3
4
  import type { AppContinuity } from "./continuity.js";
4
5
  /** Framework-neutral server adapter. Call with an already authenticated app session, never request data. */
5
6
  export type HostySessionInput = {
@@ -10,6 +11,7 @@ export type HostySessionInput = {
10
11
  activeUntil?: string | null;
11
12
  activityRequired?: boolean;
12
13
  continuity?: AppContinuity;
14
+ sandbox?: SandboxIdentityContext;
13
15
  };
14
16
  error?: {
15
17
  code: string;
@@ -7,6 +7,12 @@ export async function createHostySessionResponse(session, recovery, options = {}
7
7
  const identity = session.identity;
8
8
  if (options.administratorOnly && identity.hostRole !== "host.admin")
9
9
  return Response.json({ status: "forbidden", recovery }, { headers });
10
+ if (identity.sandbox) {
11
+ // Core admits only recipes without required global permissions in this offline profile.
12
+ // The revalidated execution identity is authoritative; never query the installed app's
13
+ // permission state or advertise a global permission-review action from a test realm.
14
+ return Response.json({ status: "active", ...identity, recovery, hosty: { version: 1, setup: "ready" } }, { headers });
15
+ }
10
16
  let setup = "unavailable";
11
17
  let notice;
12
18
  try {
@@ -1,3 +1,4 @@
1
+ import type { SandboxIdentityContext } from "./sandbox-identity.js";
1
2
  import type { ProviderServerOptions } from "@hosty-sdk/app/providers/server";
2
3
  import { type PanelTarget, type UiBootstrap, type UiPanel, type UiResolvedPanel, type UiNavigationCatalog, type UiNavigationTarget, type UiResolvedNavigation } from "@hosty-sdk/app/ui/types";
3
4
  /** Server transport for generic UI metadata. Credentials never appear in returned metadata. */
@@ -20,6 +21,7 @@ type UiAuthenticatedContext = {
20
21
  status: "active";
21
22
  identity: {
22
23
  hostRole: string | null;
24
+ sandbox?: SandboxIdentityContext;
23
25
  };
24
26
  } | {
25
27
  status: "not-present" | "expired" | "forbidden" | "unavailable" | "misconfigured";
package/dist/ui-server.js CHANGED
@@ -55,6 +55,8 @@ export async function createHostyUiResponse(request, context, options = {}) {
55
55
  }
56
56
  if (options.administratorOnly && context.session.identity.hostRole !== "host.admin")
57
57
  throw new UiError("admin_required", "This application requires administrator access.", 403);
58
+ if (context.session.identity.sandbox)
59
+ throw new UiError("unsupported_sandbox_ui", "Global application panels are unavailable inside this sandbox profile.", 409);
58
60
  const url = new URL(request.url);
59
61
  const prefix = (options.uiPath ?? "/api/hosty/ui").replace(/\/$/, "");
60
62
  const route = url.pathname.startsWith(prefix + "/") ? url.pathname.slice(prefix.length + 1) : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.27.24",
3
+ "version": "0.29.0",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -120,6 +120,14 @@
120
120
  "./next": {
121
121
  "types": "./dist/next.d.ts",
122
122
  "default": "./dist/next.js"
123
+ },
124
+ "./sandbox/server": {
125
+ "types": "./dist/sandbox-server.d.ts",
126
+ "default": "./dist/sandbox-server.js"
127
+ },
128
+ "./sandbox/react": {
129
+ "types": "./dist/sandbox-react.d.ts",
130
+ "default": "./dist/sandbox-react.js"
123
131
  }
124
132
  },
125
133
  "scripts": {