@hosty-sdk/app 0.28.0 → 0.30.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.
@@ -0,0 +1,2 @@
1
+ /** Remove consumed launch parameters immediately, then synchronize routers installed by parent effects. */
2
+ export declare function replaceConsumedUrl(url: URL): void;
@@ -0,0 +1,14 @@
1
+ /** Remove consumed launch parameters immediately, then synchronize routers installed by parent effects. */
2
+ export function replaceConsumedUrl(url) {
3
+ const browser = window;
4
+ const route = `${url.pathname}${url.search}${url.hash}`;
5
+ browser.history.replaceState(null, "", route);
6
+ // Next installs its History API adapter in a parent passive effect, after the SDK's child
7
+ // effects. Repeat the final cleanup once that effect flush ends so its canonical URL also
8
+ // changes. Otherwise HMR restores the original, already-consumed authorization callback.
9
+ // A later cleanup or navigation owns its own URL; never replace it with this older snapshot.
10
+ queueMicrotask(() => {
11
+ if (browser.location.href === url.href)
12
+ browser.history.replaceState(browser.history.state, "", route);
13
+ });
14
+ }
package/dist/react.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { captureChromeHints } from "./ui-composition.js";
4
+ import { replaceConsumedUrl } from "./consumed-url.js";
4
5
  // React slice: the client identity bridge — probe, silent recovery, and the fallback
5
6
  // cards. Prop-less by design: the app id and browser-reachable Core origin arrive in the
6
7
  // probe response's `recovery` field (request-time values, never build-time props).
@@ -161,7 +162,7 @@ export function HostLaunchBridge() {
161
162
  // ignored must not survive into a link someone copies.
162
163
  if (url.searchParams.has(LAUNCH_MODE_PARAM)) {
163
164
  url.searchParams.delete(LAUNCH_MODE_PARAM);
164
- window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
165
+ replaceConsumedUrl(url);
165
166
  }
166
167
  }, []);
167
168
  return null;
@@ -403,7 +404,7 @@ export function useAppIdentity({ probePath = "/api/auth/identity", appCodePath =
403
404
  url.searchParams.delete("state");
404
405
  if (isSilentError || protocolRequired)
405
406
  url.searchParams.delete("error");
406
- window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
407
+ replaceConsumedUrl(url);
407
408
  }
408
409
  if (code && !error && !upgradedLegacy && attempt && !exchangeRef.current) {
409
410
  exchangeRef.current = {
@@ -550,7 +551,7 @@ export function HostThemeBridge({ followSystem = true, onTheme } = {}) {
550
551
  if (url.searchParams.has(THEME_PARAM) || url.searchParams.has(THEME_PREFERENCE_PARAM)) {
551
552
  url.searchParams.delete(THEME_PARAM);
552
553
  url.searchParams.delete(THEME_PREFERENCE_PARAM);
553
- window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
554
+ replaceConsumedUrl(url);
554
555
  }
555
556
  const handleMessage = (event) => {
556
557
  const message = parseShellThemeMessage(event, window);
@@ -15,6 +15,7 @@ function link(value) {
15
15
  }
16
16
  /** Shared operator panel; each host supplies its authenticated same-origin Core transport. */
17
17
  export function CoreEnvironmentPanel({ request, storageKey, workspace, workspaceHref }) {
18
+ const [consents, setConsents] = useState([]);
18
19
  const [environments, setEnvironments] = useState([]);
19
20
  const [apps, setApps] = useState({});
20
21
  const [loading, setLoading] = useState(true);
@@ -45,6 +46,7 @@ export function CoreEnvironmentPanel({ request, storageKey, workspace, workspace
45
46
  if (signal?.aborted || !active.current || sequence !== refreshSequence.current)
46
47
  return;
47
48
  const selected = result.environments.filter(e => !workspace || e.context?.workspaceId === workspace.id);
49
+ setConsents(result.consents ?? []);
48
50
  setEnvironments(selected.filter(e => e.state !== "finished"));
49
51
  setLoading(false);
50
52
  setReadError("");
@@ -53,10 +55,14 @@ export function CoreEnvironmentPanel({ request, storageKey, workspace, workspace
53
55
  const item = selected.find(e => e.id === saved.id);
54
56
  const operation = item?.operations.find(o => o.id.replaceAll("-", "") === String(saved.body.requestId).replaceAll("-", ""));
55
57
  if (operation && ["succeeded", "failed"].includes(operation.state)) {
56
- savePending(null);
58
+ const superseded = operation !== item?.operations.at(-1);
59
+ if (operation.state === "succeeded" || saved.kind !== "finish" || superseded)
60
+ savePending(null);
57
61
  setError(operation.state === "failed" ? item?.error ?? "Sandbox operation failed." : "");
58
62
  if (item?.state === "finished")
59
63
  setNotice("Sandbox deleted. Workspace source is preserved.");
64
+ else if (superseded)
65
+ setNotice("A newer operation replaced the previous request. Review the current sandbox status.");
60
66
  }
61
67
  }
62
68
  const results = await Promise.all(selected.filter(e => e.state === "running").map(async (e) => {
@@ -73,6 +79,7 @@ export function CoreEnvironmentPanel({ request, storageKey, workspace, workspace
73
79
  catch (cause) {
74
80
  if (!signal?.aborted && active.current && sequence === refreshSequence.current) {
75
81
  setEnvironments([]);
82
+ setConsents([]);
76
83
  setApps({});
77
84
  setLoading(false);
78
85
  setReadError(message(cause));
@@ -117,7 +124,8 @@ export function CoreEnvironmentPanel({ request, storageKey, workspace, workspace
117
124
  try {
118
125
  savePending(value);
119
126
  const result = await request(value.path, value.body);
120
- savePending(null);
127
+ if (value.kind !== "finish" || result.state === "finished")
128
+ savePending(null);
121
129
  if (result.error)
122
130
  throw new Error(result.error);
123
131
  if (result.state === "finished")
@@ -153,7 +161,7 @@ export function CoreEnvironmentPanel({ request, storageKey, workspace, workspace
153
161
  }
154
162
  }
155
163
  const disabled = busy || pending !== null;
156
- 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.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))] });
164
+ 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." }), !workspace && consents.length > 0 && _jsxs("details", { className: "rounded-md border p-3", children: [_jsxs("summary", { className: "cursor-pointer text-sm font-medium", children: ["Saved launch permissions (", consents.length, ")"] }), _jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: "Revocation affects all matching launches and delegated access. Running children and their own accounts remain; use Stop or Delete sandbox to end them." }), _jsx("ul", { className: "divide-y", children: consents.map(grant => _jsxs("li", { className: "space-y-2 py-3 text-sm", children: [_jsxs("p", { children: [grant.appId, " \u00B7 ", grant.mode, " \u00B7 ", grant.state] }), _jsx("p", { className: "break-all text-xs text-muted-foreground", children: grant.target }), _jsxs("button", { className: button, disabled: disabled, onClick: () => { setBusy(true); void request(`/consents/${grant.id}/revoke`, {}).then(() => refresh()).catch(cause => setError(message(cause))).finally(() => setBusy(false)); }, children: ["Revoke permission for ", grant.appId] })] }, grant.id)) })] }), 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.access && _jsxs("div", { className: "space-y-2 text-sm", "aria-label": "Sandbox consent", children: [_jsx("p", { children: item.access.state === "operator" ? "Operator launch" : item.access.state === "active" ? item.access.saved ? "Saved launch permission" : "Permission for this run" : item.access.state === "target-changed" ? "Core target changed or unavailable; review launch permission" : "Launch permission revoked or unavailable" }), _jsx("p", { className: "text-xs text-muted-foreground", children: item.access.description }), item.access.grantId && ["active", "target-changed"].includes(item.access.state) && _jsx("button", { className: button, disabled: disabled, onClick: () => { setBusy(true); void request(`/${item.id}/consent/revoke`, {}).then(() => refresh()).catch(cause => setError(message(cause))).finally(() => setBusy(false)); }, children: "Revoke launch permission" })] }), 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))] });
157
165
  }
158
166
  function EnvironmentApplications({ value }) {
159
167
  if (!value)
@@ -131,8 +131,16 @@ export declare class SandboxClient {
131
131
  }): Promise<SandboxInstance>;
132
132
  call<T = unknown>(action: SandboxAction, input: Record<string, unknown>): Promise<T>;
133
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"];
134
+ export declare const coreEnvironmentActions: readonly ["environment-consent", "environment-create", "environment-list", "environment-status", "environment-start", "environment-stop", "environment-restart", "environment-finish", "environment-setup", "environment-api"];
135
135
  export type CoreEnvironmentAction = typeof coreEnvironmentActions[number];
136
+ export interface CoreEnvironmentSavedConsent {
137
+ id: string;
138
+ appId: string;
139
+ label: string;
140
+ mode: string;
141
+ target: string;
142
+ state: string;
143
+ }
136
144
  export interface CoreEnvironment {
137
145
  id: string;
138
146
  label: string;
@@ -150,6 +158,29 @@ export interface CoreEnvironment {
150
158
  };
151
159
  error?: string;
152
160
  operations: SandboxOperation[];
161
+ limits?: {
162
+ memoryBytes: number;
163
+ cpus: number;
164
+ pids: number;
165
+ diskBytes: number;
166
+ applicationMemoryBytes: number;
167
+ applicationCpus: number;
168
+ applicationPids: number;
169
+ };
170
+ access?: {
171
+ state: string;
172
+ grantId?: string;
173
+ saved: boolean;
174
+ description: string;
175
+ };
176
+ usage?: {
177
+ ownedDiskBytes: number;
178
+ containerMemoryBytes: number;
179
+ coreMemoryBytes: number;
180
+ state: string;
181
+ observedAt: string;
182
+ error?: string;
183
+ };
153
184
  }
154
185
  /** Full child Core transport. Requires core.environments permissions and live administrator authority.
155
186
  * Setup uses ordinary password authentication. Parent credentials are never forwarded to the child. */
@@ -93,7 +93,7 @@ export class SandboxClient {
93
93
  return result;
94
94
  }
95
95
  }
96
- export const coreEnvironmentActions = ["environment-create", "environment-list", "environment-status", "environment-start",
96
+ export const coreEnvironmentActions = ["environment-consent", "environment-create", "environment-list", "environment-status", "environment-start",
97
97
  "environment-stop", "environment-restart", "environment-finish", "environment-setup", "environment-api"];
98
98
  /** Full child Core transport. Requires core.environments permissions and live administrator authority.
99
99
  * Setup uses ordinary password authentication. Parent credentials are never forwarded to the child. */
@@ -116,8 +116,8 @@ export class CoreEnvironmentClient {
116
116
  throw new SandboxError("environment_operation_invalid", "Unknown environment operation.", 400);
117
117
  const { environmentId, ...body } = input;
118
118
  const read = action === "environment-list" || action === "environment-status";
119
- let suffix = "";
120
- if (action !== "environment-list" && action !== "environment-create") {
119
+ let suffix = action === "environment-consent" ? "/consent" : "";
120
+ if (action !== "environment-list" && action !== "environment-create" && action !== "environment-consent") {
121
121
  if (typeof environmentId !== "string" || !/^[a-f0-9]{32}$/.test(environmentId))
122
122
  throw new SandboxError("environment_id_invalid", "Select a Core-issued environment ID.", 400);
123
123
  suffix = `/${environmentId}` + (action === "environment-status" ? "" : action === "environment-api" ? "/api"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.28.0",
3
+ "version": "0.30.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",