@k2b/cloud 0.35.0 → 0.37.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.
@@ -23,6 +23,7 @@ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
23
23
  import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
24
24
  import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
25
25
  import { aiChatMessages } from "./chat/messages";
26
+ import { isFailedTool } from "./chat/tool-groups";
26
27
  import { aiTurnErrorText } from "./chat/turn-error";
27
28
  import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
28
29
  import { createCloudCompactFn } from "./compaction";
@@ -134,6 +135,17 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
134
135
  };
135
136
  };
136
137
 
138
+ const lastToolRoundFailed = (messages: AiStoredMessage[], blocks: AiTurnBlock[]): boolean => {
139
+ const latest = messages.findLast(({ message }) => message.role === "assistant")?.message;
140
+ if (latest?.role !== "assistant") return false;
141
+ const callIds = new Set(latest.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
142
+ // A worker may have stored a malformed call but crashed before its error result; load repairs that result without an event.
143
+ return (
144
+ [...callIds].some(isMalformedToolCallId) ||
145
+ blocks.some((block) => block.kind === "tool" && callIds.has(block.callId) && isFailedTool(block))
146
+ );
147
+ };
148
+
137
149
  const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
138
150
  try {
139
151
  await aiConversations.indexConversationResources(input);
@@ -1500,6 +1512,7 @@ export class AiTurnExecutor {
1500
1512
  const outcome = await this.driveChatLoop({
1501
1513
  loop,
1502
1514
  pipeline,
1515
+ initialRoundFailed: lastToolRoundFailed(turnMessages, turnBlocks),
1503
1516
  conversationId,
1504
1517
  turnId,
1505
1518
  abortController,
@@ -1572,6 +1585,7 @@ export class AiTurnExecutor {
1572
1585
  private async driveChatLoop(input: {
1573
1586
  loop: NessiLoop;
1574
1587
  pipeline: StreamPipeline;
1588
+ initialRoundFailed: boolean;
1575
1589
  conversationId: string;
1576
1590
  turnId: string;
1577
1591
  abortController: AbortController;
@@ -1613,6 +1627,10 @@ export class AiTurnExecutor {
1613
1627
  } = input;
1614
1628
  const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
1615
1629
  let lastIssueMessage: string | null = null;
1630
+ let lastResponse: AssistantMessage | undefined;
1631
+ let roundFailed = input.initialRoundFailed;
1632
+ let followedFailedRound = false;
1633
+ const malformedCallIds = new Set<string>();
1616
1634
 
1617
1635
  try {
1618
1636
  for await (const event of loop) {
@@ -1672,6 +1690,7 @@ export class AiTurnExecutor {
1672
1690
  .noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
1673
1691
  .catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
1674
1692
  const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
1693
+ if (toolBlock?.kind === "tool" && isFailedTool(toolBlock)) roundFailed = true;
1675
1694
  // The policy keys calls by the name the model called, as the persisted calls it seeds from.
1676
1695
  noteToolCall(
1677
1696
  toolBlock?.kind === "tool"
@@ -1687,20 +1706,44 @@ export class AiTurnExecutor {
1687
1706
  result: event.result,
1688
1707
  isError: event.isError === true,
1689
1708
  });
1690
- } else if (event.type === "turn_end" && !abortController.signal.aborted && isToolRound(event.message)) {
1691
- noteToolRound();
1709
+ } else if (event.type === "turn_end") {
1710
+ lastResponse = event.message;
1711
+ followedFailedRound = roundFailed;
1712
+ const calls = new Set(event.message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
1713
+ // nessi closes a round after executing its tools. Malformed calls have stored failed blocks,
1714
+ // but are added by Cloud's store wrapper, so they are absent from the event's message.
1715
+ roundFailed = pipeline.blocks.some(
1716
+ (block) => block.kind === "tool" && (calls.has(block.callId) || malformedCallIds.has(block.callId)) && isFailedTool(block),
1717
+ );
1718
+ malformedCallIds.clear();
1719
+ if (!abortController.signal.aborted && isToolRound(event.message)) noteToolRound();
1692
1720
  } else if (event.type === "issue") {
1721
+ if (event.issue.kind === "malformed_tool_call" && event.issue.callId) malformedCallIds.add(event.issue.callId);
1693
1722
  lastIssueMessage = event.issue.message;
1694
1723
  log.warn("AI turn issue", { conversationId, turnId, kind: event.issue.kind, message: event.issue.message });
1695
1724
  } else if (event.type === "loop_end") {
1725
+ const stoppedAfterFailure =
1726
+ event.reason === "stop" &&
1727
+ !abortController.signal.aborted &&
1728
+ followedFailedRound &&
1729
+ lastResponse !== undefined &&
1730
+ !lastResponse.content.some((block) => block.type === "tool_call" || (block.type === "text" && block.text.trim() !== ""));
1731
+ const doneReason = stoppedAfterFailure ? "error" : event.reason;
1696
1732
  const aggregate = await withDurableTurnTiming(turnId, event.aggregate);
1697
1733
  if (aggregate.assistantMessageCount > 0) {
1698
1734
  await aiConversations
1699
- .setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason: event.reason })
1735
+ .setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason })
1700
1736
  .catch(() => undefined);
1701
1737
  }
1702
1738
  const timing = aggregate.timing;
1703
1739
  if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
1740
+ if (stoppedAfterFailure)
1741
+ return {
1742
+ kind: "finished",
1743
+ status: "failed",
1744
+ failure: { error: { code: "tool_failed" }, detail: "The model stopped without an answer after a failed tool call." },
1745
+ timing,
1746
+ };
1704
1747
  if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
1705
1748
  const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
1706
1749
  const reason: AiTurnFailureReason =
package/src/ai/types.ts CHANGED
@@ -337,6 +337,7 @@ export type AiConversationTimelineEntry = {
337
337
  export type AiTurnErrorCode =
338
338
  | "model_unavailable"
339
339
  | "provider_stopped"
340
+ | "tool_failed"
340
341
  | "quota_exhausted"
341
342
  | "context_full"
342
343
  | "time_limit"
@@ -9,11 +9,26 @@ import { type ToastHandle, toast } from "@k2b/ui";
9
9
  */
10
10
  export const AUTOMATIC_RELOAD_WINDOW_MS = 30_000;
11
11
 
12
+ let reloadPending = false;
13
+ /** One notice at a time: every deferred reload has the same remedy. */
14
+ let pageChanged: ToastHandle | undefined;
15
+
16
+ /**
17
+ * An open modal dialog holds the reload. A modeless window, such as global search moved aside, leaves the page
18
+ * usable and may stay open for long, so it does not: the page would otherwise stay stale without notice.
19
+ */
20
+ const modalOpen = () => document.querySelector('dialog[open]:not([aria-modal="false"])') !== null;
21
+
12
22
  const storageKey = (key: string) => `cloud.reload.${key}`;
13
23
 
14
24
  /**
15
25
  * Reloads the page for an automatic reason (a live event, a failed live
16
26
  * subscription, a changed view) at most once per `key` and window in this tab.
27
+ * While a modal dialog is open, it does not reload: once the last one closes,
28
+ * it shows a notice with a reload action. The person may have confirmed work
29
+ * in that dialog whose request a reload would cancel, so only they reload.
30
+ * The call returns `true` in both cases; a dialog that shows live data says
31
+ * itself that it is stale.
17
32
  *
18
33
  * Returns `false` without reloading when the same key already reloaded within
19
34
  * the window, or when `sessionStorage` is unavailable and a repeat therefore
@@ -22,6 +37,7 @@ const storageKey = (key: string) => `cloud.reload.${key}`;
22
37
  * need this guard.
23
38
  */
24
39
  export const reloadOnce = (key: string, options: { windowMs?: number } = {}): boolean => {
40
+ if (reloadPending) return true;
25
41
  const windowMs = options.windowMs ?? AUTOMATIC_RELOAD_WINDOW_MS;
26
42
  const now = Date.now();
27
43
  try {
@@ -32,7 +48,20 @@ export const reloadOnce = (key: string, options: { windowMs?: number } = {}): bo
32
48
  } catch {
33
49
  return false;
34
50
  }
35
- window.location.reload();
51
+ if (typeof document === "undefined" || !modalOpen()) {
52
+ window.location.reload();
53
+ return true;
54
+ }
55
+ reloadPending = true;
56
+ const observer = new MutationObserver(() => {
57
+ if (modalOpen()) return;
58
+ observer.disconnect();
59
+ reloadPending = false;
60
+ const t = reloadMessages.resolve([document.documentElement.lang || "en"]).t;
61
+ pageChanged?.dismiss();
62
+ pageChanged = toast(t.pageChanged, { duration: 0, action: { label: t.reload, onClick: () => window.location.reload() } });
63
+ });
64
+ observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open", "aria-modal"] });
36
65
  return true;
37
66
  };
38
67
 
@@ -41,10 +70,12 @@ const reloadMessages = i18n.define({
41
70
  messages: {
42
71
  en: {
43
72
  codeUnavailable: "Part of this page could not load. Reload the page and try again.",
73
+ pageChanged: "This page changed. Reload it to see the latest version.",
44
74
  reload: "Reload",
45
75
  },
46
76
  de: {
47
77
  codeUnavailable: "Ein Teil dieser Seite konnte nicht geladen werden. Lade die Seite neu und versuche es erneut.",
78
+ pageChanged: "Diese Seite hat sich geändert. Lade sie neu, um den aktuellen Stand zu sehen.",
48
79
  reload: "Neu laden",
49
80
  },
50
81
  },
@@ -0,0 +1,67 @@
1
+ import { shellFocusCookie } from "../_internal/shell-focus";
2
+
3
+ export const SHELL_FOCUS_CHANGE_EVENT = "cloud:shell-focus-change";
4
+
5
+ const canvas = () => document.querySelector<HTMLElement>(".cloud-app-canvas[data-layout-authenticated]");
6
+
7
+ export const readShellFocus = (): boolean => canvas()?.dataset.shellFocus === "true";
8
+
9
+ export const setShellFocus = (hidden: boolean): void => {
10
+ const element = canvas();
11
+ if (!element) return;
12
+ const focused = document.activeElement;
13
+ document.cookie = shellFocusCookie(hidden, location.protocol === "https:");
14
+ if (hidden) element.dataset.shellFocus = "true";
15
+ else delete element.dataset.shellFocus;
16
+ window.dispatchEvent(new CustomEvent(SHELL_FOCUS_CHANGE_EVENT, { detail: { hidden } }));
17
+ if (
18
+ window.matchMedia("(min-width: 768px)").matches &&
19
+ (!focused ||
20
+ focused === document.body ||
21
+ focused.matches("[data-cloud-shell-focus-toggle]") ||
22
+ !focused.isConnected ||
23
+ focused.getClientRects().length === 0)
24
+ ) {
25
+ element.querySelector<HTMLElement>(`[data-cloud-shell-focus-toggle="${hidden ? "show" : "hide"}"]`)?.focus({ preventScroll: true });
26
+ }
27
+ };
28
+
29
+ export const toggleShellFocus = (): void => setShellFocus(!readShellFocus());
30
+
31
+ /** Delegation also handles the server's buttons before their islands hydrate. */
32
+ export const attachShellFocusControls = (): (() => void) => {
33
+ const click = (event: MouseEvent) => {
34
+ if (!(event.target instanceof Element)) return;
35
+ const button = event.target.closest<HTMLElement>("[data-cloud-shell-focus-toggle]");
36
+ if (button) setShellFocus(button.dataset.cloudShellFocusToggle === "hide");
37
+ };
38
+ let drag: { pointerId: number; x: number; y: number } | undefined;
39
+ const start = (event: PointerEvent) => {
40
+ if (event.pointerType === "mouse" || !(event.target instanceof Element) || !event.target.closest("[data-cloud-shell-reveal]")) return;
41
+ drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
42
+ };
43
+ const move = (event: PointerEvent) => {
44
+ if (!drag || event.pointerId !== drag.pointerId) return;
45
+ const dx = event.clientX - drag.x;
46
+ if (dx >= 40 && dx > Math.abs(event.clientY - drag.y)) {
47
+ drag = undefined;
48
+ setShellFocus(false);
49
+ }
50
+ };
51
+ const stop = (event: PointerEvent) => {
52
+ if (event.pointerId === drag?.pointerId) drag = undefined;
53
+ };
54
+ document.addEventListener("click", click);
55
+ document.addEventListener("pointerdown", start);
56
+ window.addEventListener("pointermove", move);
57
+ window.addEventListener("pointerup", stop);
58
+ window.addEventListener("pointercancel", stop);
59
+ return () => {
60
+ document.removeEventListener("click", click);
61
+ document.removeEventListener("pointerdown", start);
62
+ window.removeEventListener("pointermove", move);
63
+ window.removeEventListener("pointerup", stop);
64
+ window.removeEventListener("pointercancel", stop);
65
+ drag = undefined;
66
+ };
67
+ };
@@ -244,6 +244,8 @@ export const readCliInput = async (input: CliInputFlagValue, options: ReadCliInp
244
244
 
245
245
  export const defineCliCommands = (config: CliCommandsConfig): CloudCliModule => {
246
246
  const root = buildCommandTree(config.commands, config.groupSummaries);
247
+ // `cld <module> reference` prints the module's skill references instead.
248
+ if (root.children.has("reference")) throw new Error(`CLI module "${config.name}" must not declare the reserved command "reference"`);
247
249
  if (config.alias !== undefined && !isCloudCliAlias(config.alias, config.name)) {
248
250
  throw new Error("CLI alias must be a lowercase kebab-case name different from the module name");
249
251
  }
package/src/cli/plugin.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `cld` plugin contract shared by the applications that serve plugins and by
3
- * `cld`, which installs and loads them.
2
+ * Verified `cld` bundle contract shared by application-served plugins,
3
+ * package plugins, and `cld`, which installs and loads them.
4
4
  *
5
5
  * An application serves each declared CLI module under
6
6
  * `/cli/plugins/<name>/`: `manifest.json`, the bundled module `cli.js`, and
@@ -34,9 +34,9 @@ export type CloudCliPluginManifest = {
34
34
  apiVersion: number;
35
35
  /** Module name and plugin ID. */
36
36
  name: string;
37
- /** ID of the application that serves the plugin. */
37
+ /** ID of the serving application, or name of the publishing package. */
38
38
  app: string;
39
- /** Version of the serving application build. */
39
+ /** Application build or package version. */
40
40
  version: string;
41
41
  entry: typeof CLOUD_CLI_PLUGIN_ENTRY;
42
42
  /** SHA-512 over the file list; see {@link cloudCliPluginDigest}. */
@@ -88,7 +88,7 @@ export const isCloudCliShortcuts = (value: unknown, name: string, alias?: string
88
88
  .every((segment) => !segment.startsWith("-")),
89
89
  );
90
90
 
91
- /** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
91
+ /** Validate a bundle manifest, including its digest. Throws an `Error` naming the first problem. */
92
92
  export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
93
93
  if (!isRecord(value)) throw new Error("plugin manifest is not an object");
94
94
  const { apiVersion, name, app, version, entry, digest, files } = value;
@@ -218,3 +218,12 @@ export type {
218
218
  MailPage,
219
219
  MailServiceError,
220
220
  } from "../contracts/outgoing-mail";
221
+
222
+ export {
223
+ NetworkTargetBlockedError,
224
+ isNetworkTargetBlocked,
225
+ pinnedNetworkLookup,
226
+ resolvePublicNetworkAddresses,
227
+ resolveAllowedNetworkAddresses,
228
+ } from "./network-security";
229
+ export type { NetworkLookup, PublicNetworkAddress } from "./network-security";
@@ -1,6 +1,14 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
2
  import type { LookupFunction } from "node:net";
3
- import { isUnsafeNetworkAddress, isUnsafeNetworkHostname, normalizeNetworkHostname, parseNetworkAddress } from "../shared/network-address";
3
+ import {
4
+ EMPTY_NETWORK_ALLOWLIST,
5
+ isAllowlistedNetworkAddress,
6
+ isUnsafeNetworkAddress,
7
+ isUnsafeNetworkHostname,
8
+ type NetworkAllowlist,
9
+ normalizeNetworkHostname,
10
+ parseNetworkAddress,
11
+ } from "../shared/network-address";
4
12
 
5
13
  export type PublicNetworkAddress = { address: string; family: 4 | 6 };
6
14
  export type NetworkLookup = (
@@ -15,12 +23,14 @@ export class NetworkTargetBlockedError extends Error {
15
23
  }
16
24
  }
17
25
 
18
- export const resolvePublicNetworkAddresses = async (
26
+ export const resolveAllowedNetworkAddresses = async (
19
27
  rawHostname: string,
28
+ allowlist: NetworkAllowlist,
20
29
  lookup: NetworkLookup = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
21
30
  ): Promise<PublicNetworkAddress[]> => {
22
31
  const hostname = normalizeNetworkHostname(rawHostname);
23
- if (isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
32
+ const trustedHost = allowlist.hosts.has(hostname);
33
+ if (!trustedHost && isUnsafeNetworkHostname(rawHostname)) throw new NetworkTargetBlockedError("Network target host is not allowed");
24
34
 
25
35
  const literal = parseNetworkAddress(rawHostname);
26
36
  const addresses = literal ? [literal] : await lookup(hostname, { all: true, verbatim: true });
@@ -33,12 +43,46 @@ export const resolvePublicNetworkAddresses = async (
33
43
  if (!literal || literal.family !== entry.family) throw new NetworkTargetBlockedError("Network target returned an invalid address");
34
44
  return literal;
35
45
  });
36
- if (normalized.some((entry) => isUnsafeNetworkAddress(entry.address))) {
46
+ if (
47
+ !trustedHost &&
48
+ normalized.some((entry) => isUnsafeNetworkAddress(entry.address) && !isAllowlistedNetworkAddress(allowlist, entry.address))
49
+ ) {
37
50
  throw new NetworkTargetBlockedError("Network target resolved to a private or reserved address");
38
51
  }
39
52
  return normalized;
40
53
  };
41
54
 
55
+ /** Save-time hint only: every request must still resolve, validate and pin its target. */
56
+ export const isNetworkTargetBlocked = async (
57
+ hostname: string,
58
+ allowlist: NetworkAllowlist,
59
+ {
60
+ resolve = resolveAllowedNetworkAddresses,
61
+ timeoutMs = 5000,
62
+ }: {
63
+ resolve?: typeof resolveAllowedNetworkAddresses;
64
+ timeoutMs?: number;
65
+ } = {},
66
+ ): Promise<boolean> => {
67
+ let timer: ReturnType<typeof setTimeout> | undefined;
68
+ try {
69
+ await Promise.race([
70
+ resolve(hostname, allowlist),
71
+ new Promise<void>((resolve) => {
72
+ timer = setTimeout(resolve, timeoutMs);
73
+ }),
74
+ ]);
75
+ return false;
76
+ } catch (error) {
77
+ return error instanceof NetworkTargetBlockedError;
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ };
82
+
83
+ export const resolvePublicNetworkAddresses = (rawHostname: string, lookup?: NetworkLookup): Promise<PublicNetworkAddress[]> =>
84
+ resolveAllowedNetworkAddresses(rawHostname, EMPTY_NETWORK_ALLOWLIST, lookup);
85
+
42
86
  /** Socket lookup using only addresses already checked by the public resolver. */
43
87
  export const pinnedNetworkLookup =
44
88
  (addresses: readonly PublicNetworkAddress[]): LookupFunction =>
@@ -106,3 +106,83 @@ export const isUnsafeNetworkHostname = (rawHostname: string): boolean => {
106
106
  ((endsInNumber(hostname) || hostname.includes(":")) && !parseNetworkAddress(hostname))
107
107
  );
108
108
  };
109
+
110
+ /** Operator-owned exact DNS names and canonical IP networks. Never populate from person input. */
111
+ export type NetworkAllowlist = {
112
+ readonly hosts: ReadonlySet<string>;
113
+ readonly networks: ReadonlyArray<Readonly<{ address: string; family: 4 | 6; bits: number }>>;
114
+ };
115
+
116
+ export const EMPTY_NETWORK_ALLOWLIST: NetworkAllowlist = { hosts: new Set(), networks: [] };
117
+
118
+ export const parseNetworkAllowlist = (entries: readonly string[]): NetworkAllowlist => {
119
+ const hosts = new Set<string>();
120
+ const networks: Array<{ address: string; family: 4 | 6; bits: number }> = [];
121
+ for (const entry of entries) {
122
+ const invalid = (reason = "expected an exact host name, IP address or CIDR") =>
123
+ new Error(`Invalid network allowlist entry "${entry}": ${reason}`);
124
+ const parts = entry.split("/");
125
+ const raw = parts[0] ?? "";
126
+ const literal = parseNetworkAddress(raw);
127
+ if (literal) {
128
+ // Allowlist entries use strict IP notation, even though URL destinations may use legacy IPv4.
129
+ if (literal.family === 4 && ipv4ToNumber(raw) === null) throw invalid();
130
+ if (literal.family === 6 && raw.includes(".") && ipv4ToNumber(raw.replace(/\]$/, "").split(":").at(-1) ?? "") === null)
131
+ throw invalid();
132
+ const width = literal.family === 4 ? 32 : 128;
133
+ if (parts.length > 2 || (parts.length === 2 && !/^(0|[1-9]\d*)$/.test(parts[1] ?? ""))) throw invalid();
134
+ const bits = parts.length === 2 ? Number(parts[1]) : width;
135
+ if (bits > width) throw invalid();
136
+ if (bits === 0) throw invalid("a /0 network allows every address; list the internal hosts or ranges instead");
137
+ const value = literal.family === 4 ? BigInt(ipv4ToNumber(literal.address)!) : ipv6ToBigInt(literal.address);
138
+ const shift = BigInt(width - bits);
139
+ const base = (value >> shift) << shift;
140
+ if (base !== value) {
141
+ const address =
142
+ literal.family === 4
143
+ ? [24n, 16n, 8n, 0n].map((shift) => Number((base >> shift) & 255n)).join(".")
144
+ : parseNetworkAddress(
145
+ Array.from({ length: 8 }, (_, index) => ((base >> BigInt((7 - index) * 16)) & 65535n).toString(16)).join(":"),
146
+ )!.address;
147
+ throw invalid(`host bits are set; use ${address}/${bits}`);
148
+ }
149
+ networks.push({ ...literal, bits });
150
+ continue;
151
+ }
152
+ const hostname = normalizeNetworkHostname(raw);
153
+ if (
154
+ parts.length !== 1 ||
155
+ endsInNumber(hostname) ||
156
+ raw.includes(":") ||
157
+ hostname.length > 253 ||
158
+ !hostname.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
159
+ )
160
+ throw invalid();
161
+ hosts.add(hostname);
162
+ }
163
+ return { hosts, networks };
164
+ };
165
+
166
+ /** Parse operator configuration with the variable name in startup errors. */
167
+ export const parseNetworkAllowlistEnv = (variable: string, entries: readonly string[]): NetworkAllowlist => {
168
+ try {
169
+ return parseNetworkAllowlist(entries);
170
+ } catch (error) {
171
+ throw new Error(`${variable}: ${error instanceof Error ? error.message : "Invalid allowlist"}`);
172
+ }
173
+ };
174
+
175
+ export const isAllowlistedNetworkAddress = (allowlist: NetworkAllowlist, rawAddress: string): boolean => {
176
+ const literal = parseNetworkAddress(rawAddress);
177
+ if (!literal) return false;
178
+ if (literal.family === 6) {
179
+ const value = ipv6ToBigInt(literal.address);
180
+ // Only IPv4-mapped IPv6 reaches the embedded IPv4 host directly.
181
+ if (value >> 32n === 0xffffn) {
182
+ const ipv4 = [24n, 16n, 8n, 0n].map((shift) => Number((value >> shift) & 255n)).join(".");
183
+ if (isAllowlistedNetworkAddress(allowlist, ipv4)) return true;
184
+ }
185
+ return allowlist.networks.some((network) => network.family === 6 && ipv6InRange(value, ipv6ToBigInt(network.address), network.bits));
186
+ }
187
+ return allowlist.networks.some((network) => network.family === 4 && ipv4InRange(literal.address, network.address, network.bits));
188
+ };
@@ -9,6 +9,7 @@ import type { LayoutBreadcrumb } from "./layout-runtime";
9
9
  import ProfilePreferences from "./ProfilePreferences";
10
10
  import { platformMessages } from "./platform-messages";
11
11
  import RailApps from "./RailApps";
12
+ import ShellFocusToggle from "./ShellFocusToggle";
12
13
  import { useShellData } from "./shell-data";
13
14
 
14
15
  type CloudShellProps =
@@ -32,6 +33,7 @@ function RailNavigation(props: { currentPath: string }) {
32
33
  createEffect(() => setAppLaunchpadContext(data.apps, data.legalLinks, profile));
33
34
  return (
34
35
  <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t().apps}>
36
+ <ShellFocusToggle variant="rail" />
35
37
  <RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
36
38
  <AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
37
39
  <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
@@ -33,7 +33,7 @@ export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
33
33
  delay={0}
34
34
  content={t().searchShortcut}
35
35
  >
36
- <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-search"} text-base`} />
36
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-search"} text-xl`} />
37
37
  </Tooltip.Trigger>
38
38
  );
39
39
 
@@ -2,6 +2,7 @@ import type { DateContext } from "@k2b/stdlib";
2
2
  import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
3
3
  import type { JSX } from "solid-js/jsx-runtime";
4
4
  import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
5
+ import { readShellFocusCookie } from "../_internal/shell-focus";
5
6
  import { resolveNavMatch } from "../contracts/app"; // ==========================
6
7
  import { isPwaShellAvailable } from "../contracts/pwa";
7
8
  import { defaultRailPreferences } from "../contracts/rail-preferences";
@@ -25,6 +26,7 @@ import { platformMessages } from "./platform-messages";
25
26
  import { sortRailApps } from "./rail-navigation";
26
27
  import { requestPath } from "./request-path";
27
28
  import { getLocalizedRuntimeContext, type RuntimeContext } from "./runtime";
29
+ import ShellFocusToggle from "./ShellFocusToggle";
28
30
  import ShellRuntime from "./ShellRuntime.island";
29
31
  import { type ShellData, ShellDataProvider } from "./shell-data";
30
32
 
@@ -187,6 +189,8 @@ export default function Layout(props: LayoutProps) {
187
189
  ? { title: pageTitle, ancestors: [] }
188
190
  : derivePageTrail(title ?? appName, currentRoute?.prefix, appName, `${pathname}${new URL(c.req.raw.url).search}`);
189
191
  const showRail = !!user;
192
+ // The app bar and the app's navigation hidden with Cmd/Ctrl+\ or the sidebar buttons; phones ignore it in CSS.
193
+ const shellFocus = showRail && readShellFocusCookie(cookie);
190
194
  // The profile menu offers the mobile app only while the installation runs it.
191
195
  const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
192
196
  const profileName = user?.displayName || user?.uid || "?";
@@ -248,11 +252,13 @@ export default function Layout(props: LayoutProps) {
248
252
  data-page-trail-title-only={titleOnly ? "" : undefined}
249
253
  data-layout-full-page={fullPage ? "true" : undefined}
250
254
  data-layout-authenticated={showRail ? "true" : undefined}
255
+ data-shell-focus={shellFocus ? "true" : undefined}
251
256
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
252
257
  >
253
258
  <ShellRuntime
254
259
  workspace={{ appId: currentApp?.id }}
255
260
  pageTrail
261
+ focus={showRail}
256
262
  help={runtimeHelp}
257
263
  commands={{ searchResources: !!user }}
258
264
  push={!!user}
@@ -265,6 +271,8 @@ export default function Layout(props: LayoutProps) {
265
271
  />
266
272
  <ShellDataProvider data={shellData}>
267
273
  {showRail && <LayoutRail currentPath={requestPath(c)} />}
274
+ {showRail && <ShellFocusToggle variant="float" />}
275
+ {showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
268
276
  <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
269
277
  <LayoutHeader
270
278
  authenticated={showRail}
@@ -31,7 +31,7 @@ export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
31
31
  delay={0}
32
32
  content={t().helpShortcut}
33
33
  >
34
- <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-base`} />
34
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-xl`} />
35
35
  </Tooltip.Trigger>
36
36
  );
37
37
 
@@ -9,7 +9,7 @@ export default function LayoutRail(props: { currentPath: string }) {
9
9
  const locale = useLocale();
10
10
  const t = () => platformMessages.resolve([locale()]).t;
11
11
  return (
12
- <aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
12
+ <aside class="layout-rail hidden w-14 shrink-0 flex-col md:flex">
13
13
  <div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
14
14
  <a href="/" aria-label={t().home}>
15
15
  <img src="/branding/logo" alt="Logo" class="h-5 w-5" />
@@ -18,7 +18,7 @@ type ProfilePreferencesProps = {
18
18
  export default function ProfilePreferences(props: ProfilePreferencesProps) {
19
19
  const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
20
20
 
21
- const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
21
+ const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="sm" />;
22
22
 
23
23
  return (
24
24
  <div class="layout-profile-preferences">
@@ -21,7 +21,7 @@ function RailIcon(props: { link: RailLink; currentPath: string }) {
21
21
  aria-current={active() ? "page" : undefined}
22
22
  style={appAccentStyle(props.link.accent)}
23
23
  >
24
- <i class={`${props.link.iconClass} text-base`} aria-hidden="true" />
24
+ <i class={`${props.link.iconClass} text-xl`} aria-hidden="true" />
25
25
  <span class="sr-only">{props.link.label}</span>
26
26
  <AppBadge endpoint={props.link.badge} appId={props.link.appId} />
27
27
  </a>
@@ -0,0 +1,43 @@
1
+ import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
+ import { platformMessages } from "./platform-messages";
3
+
4
+ /**
5
+ * The two buttons of the shell's focus state. The app bar's button hides the
6
+ * app bar and the app's navigation; the floating one shows them again. Both
7
+ * are always rendered, and the canvas's `data-shell-focus` decides in CSS which
8
+ * one shows, so the server's first frame is final. `ShellRuntime` handles
9
+ * their clicks by `data-cloud-shell-focus-toggle`, so the floating button is
10
+ * plain server markup, with its shortcut in the native title, and needs no
11
+ * island of its own.
12
+ */
13
+ export default function ShellFocusToggle(props: { variant: "rail" | "float" }) {
14
+ const locale = useLocale();
15
+ const t = () => platformMessages.resolve([locale()]).t;
16
+ if (props.variant === "rail")
17
+ return (
18
+ <Tooltip.Trigger
19
+ type="button"
20
+ class="rail-item"
21
+ data-cloud-shell-focus-toggle="hide"
22
+ aria-label={t().hideSidebar}
23
+ placement="right"
24
+ delay={0}
25
+ content={t().hideSidebarShortcut}
26
+ >
27
+ <i class="ti ti-layout-sidebar-left-collapse text-xl" aria-hidden="true" />
28
+ </Tooltip.Trigger>
29
+ );
30
+ return (
31
+ <div class="cloud-shell-show">
32
+ <IconButton
33
+ variant="secondary"
34
+ size="lg"
35
+ data-cloud-shell-focus-toggle="show"
36
+ label={t().showSidebar}
37
+ title={t().showSidebarShortcut}
38
+ >
39
+ <i class="ti ti-layout-sidebar-left-expand text-xl" aria-hidden="true" />
40
+ </IconButton>
41
+ </div>
42
+ );
43
+ }