From 7e8f5ca71b7de1490a0db7f627dbc32a76fdee86 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 04:13:32 +0000 Subject: [PATCH 001/215] fix(ui): centralize control model ref handling --- ui/src/ui/app-chat.test.ts | 16 +++- ui/src/ui/app-chat.ts | 8 +- ui/src/ui/app-render.helpers.ts | 33 +++---- ui/src/ui/app-view-state.ts | 3 +- ui/src/ui/app.ts | 3 +- ui/src/ui/chat-model-ref.test.ts | 50 ++++++++++ ui/src/ui/chat-model-ref.ts | 93 +++++++++++++++++++ .../chat/slash-command-executor.node.test.ts | 34 ++++++- ui/src/ui/chat/slash-command-executor.ts | 22 ++++- ui/src/ui/types.ts | 9 +- ui/src/ui/views/chat.browser.test.ts | 2 +- ui/src/ui/views/chat.test.ts | 84 ++++++++++++++--- ui/src/ui/views/sessions.test.ts | 2 +- 13 files changed, 310 insertions(+), 49 deletions(-) create mode 100644 ui/src/ui/chat-model-ref.test.ts create mode 100644 ui/src/ui/chat-model-ref.ts diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9a3e86d375d..b0df28cd947 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -83,7 +83,14 @@ describe("handleSendChat", () => { ); const request = vi.fn(async (method: string, _params?: unknown) => { if (method === "sessions.patch") { - return { ok: true, key: "main" }; + return { + ok: true, + key: "main", + resolved: { + modelProvider: "openai", + model: "gpt-5-mini", + }, + }; } if (method === "chat.history") { return { messages: [], thinkingLevel: null }; @@ -93,7 +100,7 @@ describe("handleSendChat", () => { ts: 0, path: "", count: 0, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [], }; } @@ -116,6 +123,9 @@ describe("handleSendChat", () => { key: "main", model: "gpt-5-mini", }); - expect(host.chatModelOverrides.main).toBe("gpt-5-mini"); + expect(host.chatModelOverrides.main).toEqual({ + kind: "qualified", + value: "openai/gpt-5-mini", + }); }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c877b4c5a5d..ec5f7300000 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -10,7 +10,7 @@ import { loadModels } from "./controllers/models.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; -import type { ModelCatalogEntry } from "./types.ts"; +import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -29,7 +29,7 @@ export type ChatHost = { basePath: string; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; - chatModelOverrides: Record; + chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; updateComplete?: Promise; @@ -308,10 +308,10 @@ async function dispatchSlashCommand( injectCommandResult(host, result.content); } - if (result.sessionPatch && "model" in result.sessionPatch) { + if (result.sessionPatch && "modelOverride" in result.sessionPatch) { host.chatModelOverrides = { ...host.chatModelOverrides, - [targetSessionKey]: result.sessionPatch.model ?? null, + [targetSessionKey]: result.sessionPatch.modelOverride ?? null, }; } diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 12e239cb50d..e83825ab899 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -6,6 +6,13 @@ import { refreshChat } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { OpenClawApp } from "./app.ts"; +import { + buildChatModelOption, + createChatModelOverride, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolveServerChatModelValue, +} from "./chat-model-ref.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; @@ -521,8 +528,8 @@ function resolveActiveSessionRow(state: AppViewState) { function resolveModelOverrideValue(state: AppViewState): string { // Prefer the local cache — it reflects in-flight patches before sessionsResult refreshes. const cached = state.chatModelOverrides[state.sessionKey]; - if (typeof cached === "string") { - return cached.trim(); + if (cached) { + return normalizeChatModelOverrideValue(cached, state.chatModelCatalog ?? []); } // cached === null means explicitly cleared to default. if (cached === null) { @@ -532,21 +539,14 @@ function resolveModelOverrideValue(state: AppViewState): string { // Include provider prefix so the value matches option keys (provider/model). const activeRow = resolveActiveSessionRow(state); if (activeRow && typeof activeRow.model === "string" && activeRow.model.trim()) { - const provider = activeRow.modelProvider?.trim(); - const model = activeRow.model.trim(); - return provider ? `${provider}/${model}` : model; + return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider); } return ""; } function resolveDefaultModelValue(state: AppViewState): string { const defaults = state.sessionsResult?.defaults; - const model = defaults?.model; - if (typeof model !== "string" || !model.trim()) { - return ""; - } - const provider = defaults?.modelProvider?.trim(); - return provider ? `${provider}/${model.trim()}` : model.trim(); + return resolveServerChatModelValue(defaults?.model, defaults?.modelProvider); } function buildChatModelOptions( @@ -570,9 +570,8 @@ function buildChatModelOptions( }; for (const entry of catalog) { - const provider = entry.provider?.trim(); - const value = provider ? `${provider}/${entry.id}` : entry.id; - addOption(value, provider ? `${entry.id} · ${provider}` : entry.id); + const option = buildChatModelOption(entry); + addOption(option.value, option.label); } if (currentOverride) { @@ -592,9 +591,7 @@ function renderChatModelSelect(state: AppViewState) { currentOverride, defaultModel, ); - const defaultDisplay = defaultModel.includes("/") - ? `${defaultModel.slice(defaultModel.indexOf("/") + 1)} · ${defaultModel.slice(0, defaultModel.indexOf("/"))}` - : defaultModel; + const defaultDisplay = formatChatModelDisplay(defaultModel); const defaultLabel = defaultModel ? `Default (${defaultDisplay})` : "Default model"; const busy = state.chatLoading || state.chatSending || Boolean(state.chatRunId) || state.chatStream !== null; @@ -639,7 +636,7 @@ async function switchChatModel(state: AppViewState, nextModel: string) { // Write the override cache immediately so the picker stays in sync during the RPC round-trip. state.chatModelOverrides = { ...state.chatModelOverrides, - [targetSessionKey]: nextModel || null, + [targetSessionKey]: createChatModelOverride(nextModel), }; try { await state.client.request("sessions.patch", { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ad2910625b6..375faa43137 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -21,6 +21,7 @@ import type { HealthSummary, LogEntry, LogLevel, + ChatModelOverride, ModelCatalogEntry, NostrProfile, PresenceEntry, @@ -71,7 +72,7 @@ export type AppViewState = { fallbackStatus: FallbackStatus | null; chatAvatarUrl: string | null; chatThinkingLevel: string | null; - chatModelOverrides: Record; + chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; chatQueue: ChatQueueItem[]; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1b3971a41f6..af0d0cb9c96 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -69,6 +69,7 @@ import type { AgentIdentityResult, ConfigSnapshot, ConfigUiHints, + ChatModelOverride, CronJob, CronRunLogEntry, CronStatus, @@ -158,7 +159,7 @@ export class OpenClawApp extends LitElement { @state() fallbackStatus: FallbackStatus | null = null; @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; - @state() chatModelOverrides: Record = {}; + @state() chatModelOverrides: Record = {}; @state() chatModelsLoading = false; @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; diff --git a/ui/src/ui/chat-model-ref.test.ts b/ui/src/ui/chat-model-ref.test.ts new file mode 100644 index 00000000000..86b46f3fe7f --- /dev/null +++ b/ui/src/ui/chat-model-ref.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + buildChatModelOption, + createChatModelOverride, + formatChatModelDisplay, + normalizeChatModelOverrideValue, + resolveServerChatModelValue, +} from "./chat-model-ref.ts"; +import type { ModelCatalogEntry } from "./types.ts"; + +const catalog: ModelCatalogEntry[] = [ + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic" }, +]; + +describe("chat-model-ref helpers", () => { + it("builds provider-qualified option values and labels", () => { + expect(buildChatModelOption(catalog[0])).toEqual({ + value: "openai/gpt-5-mini", + label: "gpt-5-mini · openai", + }); + }); + + it("normalizes raw overrides when the catalog match is unique", () => { + expect(normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), catalog)).toBe( + "openai/gpt-5-mini", + ); + }); + + it("keeps ambiguous raw overrides unchanged", () => { + const ambiguousCatalog: ModelCatalogEntry[] = [ + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openai" }, + { id: "gpt-5-mini", name: "GPT-5 Mini", provider: "openrouter" }, + ]; + + expect( + normalizeChatModelOverrideValue(createChatModelOverride("gpt-5-mini"), ambiguousCatalog), + ).toBe("gpt-5-mini"); + }); + + it("formats qualified model refs consistently for default labels", () => { + expect(formatChatModelDisplay("openai/gpt-5-mini")).toBe("gpt-5-mini · openai"); + expect(formatChatModelDisplay("alias-only")).toBe("alias-only"); + }); + + it("resolves server session data to qualified option values", () => { + expect(resolveServerChatModelValue("gpt-5-mini", "openai")).toBe("openai/gpt-5-mini"); + expect(resolveServerChatModelValue("alias-only", null)).toBe("alias-only"); + }); +}); diff --git a/ui/src/ui/chat-model-ref.ts b/ui/src/ui/chat-model-ref.ts new file mode 100644 index 00000000000..351b8544bad --- /dev/null +++ b/ui/src/ui/chat-model-ref.ts @@ -0,0 +1,93 @@ +import type { ModelCatalogEntry } from "./types.ts"; + +export type ChatModelOverride = + | { + kind: "qualified"; + value: string; + } + | { + kind: "raw"; + value: string; + }; + +export function buildQualifiedChatModelValue(model: string, provider?: string | null): string { + const trimmedModel = model.trim(); + if (!trimmedModel) { + return ""; + } + const trimmedProvider = provider?.trim(); + return trimmedProvider ? `${trimmedProvider}/${trimmedModel}` : trimmedModel; +} + +export function createChatModelOverride(value: string): ChatModelOverride | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (trimmed.includes("/")) { + return { kind: "qualified", value: trimmed }; + } + return { kind: "raw", value: trimmed }; +} + +export function normalizeChatModelOverrideValue( + override: ChatModelOverride | null | undefined, + catalog: ModelCatalogEntry[], +): string { + if (!override) { + return ""; + } + const trimmed = override?.value.trim(); + if (!trimmed) { + return ""; + } + if (override.kind === "qualified") { + return trimmed; + } + + let matchedValue = ""; + for (const entry of catalog) { + if (entry.id.trim().toLowerCase() !== trimmed.toLowerCase()) { + continue; + } + const candidate = buildQualifiedChatModelValue(entry.id, entry.provider); + if (!matchedValue) { + matchedValue = candidate; + continue; + } + if (matchedValue.toLowerCase() !== candidate.toLowerCase()) { + return trimmed; + } + } + return matchedValue || trimmed; +} + +export function resolveServerChatModelValue( + model?: string | null, + provider?: string | null, +): string { + if (typeof model !== "string") { + return ""; + } + return buildQualifiedChatModelValue(model, provider); +} + +export function formatChatModelDisplay(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + const separator = trimmed.indexOf("/"); + if (separator <= 0) { + return trimmed; + } + return `${trimmed.slice(separator + 1)} · ${trimmed.slice(0, separator)}`; +} + +export function buildChatModelOption(entry: ModelCatalogEntry): { value: string; label: string } { + const provider = entry.provider?.trim(); + return { + value: buildQualifiedChatModelValue(entry.id, provider), + label: provider ? `${entry.id} · ${provider}` : entry.id, + }; +} diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index d08c62b97d9..96170fa8940 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -235,7 +235,7 @@ describe("executeSlashCommand directives", () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { return { - defaults: { model: "default-model" }, + defaults: { modelProvider: "openai", model: "default-model" }, sessions: [ row("agent:main:main", { model: "gpt-4.1-mini", @@ -265,6 +265,38 @@ describe("executeSlashCommand directives", () => { expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); }); + it("mirrors resolved provider-qualified model refs after /model changes", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.patch") { + return { + ok: true, + key: "main", + resolved: { + modelProvider: "openai", + model: "gpt-5-mini", + }, + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "model", + "gpt-5-mini", + ); + + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "main", + model: "gpt-5-mini", + }); + expect(result.sessionPatch?.modelOverride).toEqual({ + kind: "qualified", + value: "openai/gpt-5-mini", + }); + }); + it("resolves the legacy main alias for /usage", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 38b1690fe29..1db10dd93d6 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -16,8 +16,15 @@ import { isSubagentSessionKey, parseAgentSessionKey, } from "../../../../src/routing/session-key.js"; +import { createChatModelOverride, resolveServerChatModelValue } from "../chat-model-ref.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { AgentsListResult, GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { + AgentsListResult, + ChatModelOverride, + GatewaySessionRow, + SessionsListResult, + SessionsPatchResult, +} from "../types.ts"; import { SLASH_COMMANDS } from "./slash-commands.ts"; export type SlashCommandResult = { @@ -35,7 +42,7 @@ export type SlashCommandResult = { | "navigate-usage"; /** Optional session-level directive changes that the caller should mirror locally. */ sessionPatch?: { - model?: string | null; + modelOverride?: ChatModelOverride | null; }; }; @@ -144,11 +151,18 @@ async function executeModel( } try { - await client.request("sessions.patch", { key: sessionKey, model: args.trim() }); + const patched = await client.request("sessions.patch", { + key: sessionKey, + model: args.trim(), + }); + const resolvedValue = resolveServerChatModelValue( + patched.resolved?.model ?? args.trim(), + patched.resolved?.modelProvider, + ); return { content: `Model set to \`${args.trim()}\`.`, action: "refresh", - sessionPatch: { model: args.trim() }, + sessionPatch: { modelOverride: createChatModelOverride(resolvedValue) }, }; } catch (err) { return { content: `Failed to set model: ${String(err)}` }; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 82c97c6744a..0d5aa3d61cd 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -321,6 +321,8 @@ export type GatewaySessionsDefaults = { contextTokens: number | null; }; +export type ChatModelOverride = import("./chat-model-ref.ts").ChatModelOverride; + export type GatewayAgentRow = SharedGatewayAgentRow; export type AgentsListResult = { @@ -402,7 +404,12 @@ export type SessionsPatchResult = SessionsPatchResultBase<{ verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; -}>; +}> & { + resolved?: { + modelProvider?: string; + model?: string; + }; +}; export type { CostUsageDailyEntry, diff --git a/ui/src/ui/views/chat.browser.test.ts b/ui/src/ui/views/chat.browser.test.ts index fa7947a328a..c17525bb60b 100644 --- a/ui/src/ui/views/chat.browser.test.ts +++ b/ui/src/ui/views/chat.browser.test.ts @@ -31,7 +31,7 @@ function createProps(overrides: Partial = {}): ChatProps { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: "main", diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index ab55db6935f..eea76e6482b 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -15,7 +15,7 @@ function createSessions(): SessionsListResult { ts: 0, path: "", count: 0, - defaults: { model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null }, sessions: [], }; } @@ -28,6 +28,7 @@ function createChatHeaderState( } = {}, ): { state: AppViewState; request: ReturnType } { let currentModel = overrides.model ?? null; + let currentModelProvider = currentModel ? "openai" : undefined; const omitSessionFromList = overrides.omitSessionFromList ?? false; const catalog = overrides.models ?? [ { id: "gpt-5", name: "GPT-5", provider: "openai" }, @@ -35,7 +36,26 @@ function createChatHeaderState( ]; const request = vi.fn(async (method: string, params: Record) => { if (method === "sessions.patch") { - currentModel = (params.model as string | null | undefined) ?? null; + const nextModel = (params.model as string | null | undefined) ?? null; + if (!nextModel) { + currentModel = null; + currentModelProvider = undefined; + } else { + const normalized = nextModel.trim(); + const slashIndex = normalized.indexOf("/"); + if (slashIndex > 0) { + currentModelProvider = normalized.slice(0, slashIndex); + currentModel = normalized.slice(slashIndex + 1); + } else { + currentModel = normalized; + const matchingProviders = catalog + .filter((entry) => entry.id === normalized) + .map((entry) => entry.provider) + .filter(Boolean); + currentModelProvider = + matchingProviders.length === 1 ? matchingProviders[0] : currentModelProvider; + } + } return { ok: true, key: "main" }; } if (method === "chat.history") { @@ -46,10 +66,18 @@ function createChatHeaderState( ts: 0, path: "", count: omitSessionFromList ? 0 : 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: omitSessionFromList ? [] - : [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }], + : [ + { + key: "main", + kind: "direct", + updatedAt: null, + modelProvider: currentModelProvider, + model: currentModel, + }, + ], }; } if (method === "models.list") { @@ -65,10 +93,18 @@ function createChatHeaderState( ts: 0, path: "", count: omitSessionFromList ? 0 : 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: omitSessionFromList ? [] - : [{ key: "main", kind: "direct", updatedAt: null, model: currentModel }], + : [ + { + key: "main", + kind: "direct", + updatedAt: null, + modelProvider: currentModelProvider, + model: currentModel, + }, + ], }, chatModelOverrides: {}, chatModelCatalog: catalog, @@ -566,13 +602,13 @@ describe("chat view", () => { expect(modelSelect).not.toBeNull(); expect(modelSelect?.value).toBe(""); - modelSelect!.value = "gpt-5-mini"; + modelSelect!.value = "openai/gpt-5-mini"; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); expect(request).toHaveBeenCalledWith("sessions.patch", { key: "main", - model: "gpt-5-mini", + model: "openai/gpt-5-mini", }); expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything()); expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); @@ -594,7 +630,7 @@ describe("chat view", () => { 'select[data-chat-model-select="true"]', ); expect(modelSelect).not.toBeNull(); - expect(modelSelect?.value).toBe("gpt-5-mini"); + expect(modelSelect?.value).toBe("openai/gpt-5-mini"); modelSelect!.value = ""; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); @@ -638,7 +674,7 @@ describe("chat view", () => { ); expect(modelSelect).not.toBeNull(); - modelSelect!.value = "gpt-5-mini"; + modelSelect!.value = "openai/gpt-5-mini"; modelSelect!.dispatchEvent(new Event("change", { bubbles: true })); await flushTasks(); render(renderChatSessionSelect(state), container); @@ -646,10 +682,30 @@ describe("chat view", () => { const rerendered = container.querySelector( 'select[data-chat-model-select="true"]', ); - expect(rerendered?.value).toBe("gpt-5-mini"); + expect(rerendered?.value).toBe("openai/gpt-5-mini"); vi.unstubAllGlobals(); }); + it("normalizes cached bare /model overrides to the matching catalog option", () => { + const { state } = createChatHeaderState(); + state.chatModelOverrides = { main: { kind: "raw", value: "gpt-5-mini" } }; + + const container = document.createElement("div"); + render(renderChatSessionSelect(state), container); + + const modelSelect = container.querySelector( + 'select[data-chat-model-select="true"]', + ); + expect(modelSelect).not.toBeNull(); + expect(modelSelect?.value).toBe("openai/gpt-5-mini"); + + const optionValues = Array.from(modelSelect?.querySelectorAll("option") ?? []).map( + (option) => option.value, + ); + expect(optionValues).toContain("openai/gpt-5-mini"); + expect(optionValues).not.toContain("gpt-5-mini"); + }); + it("prefers the session label over displayName in the grouped chat session selector", () => { const { state } = createChatHeaderState({ omitSessionFromList: true }); state.sessionKey = "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b"; @@ -658,7 +714,7 @@ describe("chat view", () => { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: state.sessionKey, @@ -708,7 +764,7 @@ describe("chat view", () => { ts: 0, path: "", count: 1, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: state.sessionKey, @@ -737,7 +793,7 @@ describe("chat view", () => { ts: 0, path: "", count: 2, - defaults: { model: "gpt-5", contextTokens: null }, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, sessions: [ { key: "agent:main:subagent:4f2146de-887b-4176-9abe-91140082959b", diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index fe650fef8fb..342af136a75 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -8,7 +8,7 @@ function buildResult(session: SessionsListResult["sessions"][number]): SessionsL ts: Date.now(), path: "(multiple)", count: 1, - defaults: { model: null, contextTokens: null }, + defaults: { modelProvider: null, model: null, contextTokens: null }, sessions: [session], }; } From cb4a298961ca1292e49afc8d010013f64cb06bcd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:11:39 -0700 Subject: [PATCH 002/215] CLI: route gateway status through daemon status --- src/cli/program/routes.test.ts | 62 ++++++++++++++++++++++++---------- src/cli/program/routes.ts | 30 ++++++++++------ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 65cba06e299..87849fb4d0b 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -5,7 +5,7 @@ const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {})); const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); -const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const runDaemonStatusMock = vi.hoisted(() => vi.fn(async () => {})); const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ @@ -18,8 +18,8 @@ vi.mock("../../commands/models.js", () => ({ modelsStatusCommand: modelsStatusCommandMock, })); -vi.mock("../../commands/gateway-status.js", () => ({ - gatewayStatusCommand: gatewayStatusCommandMock, +vi.mock("../daemon-cli/status.js", () => ({ + runDaemonStatus: runDaemonStatusMock, })); vi.mock("../../commands/status-json.js", () => ({ @@ -77,14 +77,24 @@ describe("program routes", () => { ["gateway", "status"], ["node", "openclaw", "gateway", "status", "--timeout"], ); - await expectRunFalse(["gateway", "status"], ["node", "openclaw", "gateway", "status", "--ssh"]); + }); + + it("returns false for gateway status route when probe-only flags are present", async () => { await expectRunFalse( ["gateway", "status"], - ["node", "openclaw", "gateway", "status", "--ssh-identity"], + ["node", "openclaw", "gateway", "status", "--ssh", "user@host"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-identity", "~/.ssh/id_test"], + ); + await expectRunFalse( + ["gateway", "status"], + ["node", "openclaw", "gateway", "status", "--ssh-auto"], ); }); - it("passes parsed gateway status flags through", async () => { + it("passes parsed gateway status flags through to daemon status", async () => { const route = expectRoute(["gateway", "status"]); await expect( route?.run([ @@ -102,27 +112,43 @@ describe("program routes", () => { "def", "--timeout", "5000", - "--ssh", - "user@host", - "--ssh-identity", - "~/.ssh/id_test", - "--ssh-auto", + "--deep", + "--require-rpc", "--json", ]), ).resolves.toBe(true); - expect(gatewayStatusCommandMock).toHaveBeenCalledWith( - { + expect(runDaemonStatusMock).toHaveBeenCalledWith({ + rpc: { url: "ws://127.0.0.1:18789", token: "abc", password: "def", timeout: "5000", - json: true, - ssh: "user@host", - sshIdentity: "~/.ssh/id_test", - sshAuto: true, }, - expect.any(Object), + probe: true, + requireRpc: true, + deep: true, + json: true, + }); + }); + + it("passes --no-probe through to daemon status", async () => { + const route = expectRoute(["gateway", "status"]); + await expect(route?.run(["node", "openclaw", "gateway", "status", "--no-probe"])).resolves.toBe( + true, ); + + expect(runDaemonStatusMock).toHaveBeenCalledWith({ + rpc: { + url: undefined, + token: undefined, + password: undefined, + timeout: undefined, + }, + probe: false, + requireRpc: false, + deep: false, + json: false, + }); }); it("returns false when status timeout flag value is missing", async () => { diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 913f84dd2e4..cbb6d6dbfdc 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -81,26 +81,36 @@ const routeGatewayStatus: RouteSpec = { if (ssh === null) { return false; } + if (ssh !== undefined) { + return false; + } const sshIdentity = getFlagValue(argv, "--ssh-identity"); if (sshIdentity === null) { return false; } - const sshAuto = hasFlag(argv, "--ssh-auto"); + if (sshIdentity !== undefined) { + return false; + } + if (hasFlag(argv, "--ssh-auto")) { + return false; + } + const deep = hasFlag(argv, "--deep"); const json = hasFlag(argv, "--json"); - const { gatewayStatusCommand } = await import("../../commands/gateway-status.js"); - await gatewayStatusCommand( - { + const requireRpc = hasFlag(argv, "--require-rpc"); + const probe = !hasFlag(argv, "--no-probe"); + const { runDaemonStatus } = await import("../daemon-cli/status.js"); + await runDaemonStatus({ + rpc: { url: url ?? undefined, token: token ?? undefined, password: password ?? undefined, timeout: timeout ?? undefined, - json, - ssh: ssh ?? undefined, - sshIdentity: sshIdentity ?? undefined, - sshAuto, }, - defaultRuntime, - ); + probe, + requireRpc, + deep, + json, + }); return true; }, }; From 7781f62d33518a67e25309fa12c811d272e1cdb8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 21:28:56 -0700 Subject: [PATCH 003/215] Status: restore lazy scan runtime typing --- src/commands/status.scan.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 64a17e2b371..a74b9bbc131 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -48,6 +48,10 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; +type StatusScanRuntimeModule = typeof import("./status.scan.runtime.js"); +type ChannelStatusIssues = ReturnType; +type ChannelsTable = Awaited>; + function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; @@ -159,9 +163,9 @@ export type StatusScanResult = { gatewayProbe: Awaited> | null; gatewayReachable: boolean; gatewaySelf: ReturnType; - channelIssues: ReturnType; + channelIssues: ChannelStatusIssues; agentStatus: Awaited>; - channels: Awaited>; + channels: ChannelsTable; summary: Awaited>; memory: MemoryStatusSnapshot | null; memoryPlugin: MemoryPluginStatus; From 33edb57e745b4e3fab788248fa8b52cbb5727062 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:30:10 -0700 Subject: [PATCH 004/215] fix: keep provider resolution from clobbering channel plugins --- src/commands/status.scan.ts | 8 ++++---- src/plugins/provider-runtime.ts | 2 ++ src/plugins/providers.ts | 4 ++++ ui/src/ui/views/chat.test.ts | 1 + 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index a74b9bbc131..4ef90bf1da0 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -48,10 +48,6 @@ type GatewayProbeSnapshot = { let pluginRegistryModulePromise: Promise | undefined; let statusScanRuntimeModulePromise: Promise | undefined; -type StatusScanRuntimeModule = typeof import("./status.scan.runtime.js"); -type ChannelStatusIssues = ReturnType; -type ChannelsTable = Awaited>; - function loadPluginRegistryModule() { pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); return pluginRegistryModulePromise; @@ -62,6 +58,10 @@ function loadStatusScanRuntimeModule() { return statusScanRuntimeModulePromise; } +type StatusScanRuntimeModule = Awaited>; +type ChannelStatusIssues = ReturnType; +type ChannelsTable = Awaited>; + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 8997011a7c9..41c0a70ec4d 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -39,6 +39,8 @@ function resolveProviderPluginsForHooks(params: { }): ProviderPlugin[] { return resolvePluginProviders({ ...params, + activate: false, + cache: false, bundledProviderAllowlistCompat: true, bundledProviderVitestCompat: true, }); diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index e3215f2c6da..37f937d5a91 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -122,6 +122,8 @@ export function resolvePluginProviders(params: { bundledProviderAllowlistCompat?: boolean; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; + activate?: boolean; + cache?: boolean; }): ProviderPlugin[] { const maybeAllowlistCompat = params.bundledProviderAllowlistCompat ? withBundledPluginAllowlistCompat({ @@ -140,6 +142,8 @@ export function resolvePluginProviders(params: { workspaceDir: params.workspaceDir, env: params.env, onlyPluginIds: params.onlyPluginIds, + activate: params.activate, + cache: params.cache, logger: createPluginLoaderLogger(log), }); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index eea76e6482b..6907cafa0ed 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -612,6 +612,7 @@ describe("chat view", () => { }); expect(request).not.toHaveBeenCalledWith("chat.history", expect.anything()); expect(state.sessionsResult?.sessions[0]?.model).toBe("gpt-5-mini"); + expect(state.sessionsResult?.sessions[0]?.modelProvider).toBe("openai"); vi.unstubAllGlobals(); }); From b8bb8510a2a382632cae058200c9e90a591e1ffd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:35:20 -0700 Subject: [PATCH 005/215] feat: move ssh sandboxing into core --- CHANGELOG.md | 1 + docs/cli/sandbox.md | 19 +- docs/gateway/configuration-reference.md | 35 +- docs/gateway/sandboxing.md | 55 ++ docs/gateway/secrets.md | 3 + extensions/openshell/src/backend.test.ts | 1 + extensions/openshell/src/backend.ts | 41 +- extensions/openshell/src/cli.ts | 124 +--- extensions/openshell/src/remote-fs-bridge.ts | 554 +----------------- src/agents/sandbox-merge.test.ts | 36 ++ src/agents/sandbox.ts | 19 + src/agents/sandbox/backend.ts | 12 +- src/agents/sandbox/browser.create.test.ts | 6 + src/agents/sandbox/config.ts | 59 ++ .../docker.config-hash-recreate.test.ts | 6 + src/agents/sandbox/manage.ts | 9 +- src/agents/sandbox/prune.ts | 9 +- src/agents/sandbox/remote-fs-bridge.ts | 518 ++++++++++++++++ src/agents/sandbox/ssh-backend.ts | 303 ++++++++++ src/agents/sandbox/ssh.test.ts | 61 ++ src/agents/sandbox/ssh.ts | 334 +++++++++++ src/agents/sandbox/types.ts | 15 + src/config/types.agents-shared.ts | 3 + src/config/types.sandbox.ts | 27 + src/config/zod-schema.agent-runtime.ts | 18 + src/plugin-sdk/core.ts | 15 + src/secrets/runtime-config-collectors-core.ts | 85 +++ src/secrets/runtime.test.ts | 40 ++ 28 files changed, 1724 insertions(+), 684 deletions(-) create mode 100644 src/agents/sandbox/remote-fs-bridge.ts create mode 100644 src/agents/sandbox/ssh-backend.ts create mode 100644 src/agents/sandbox/ssh.test.ts create mode 100644 src/agents/sandbox/ssh.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07937512400..ea4239d1e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. - secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. - Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only. +- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode. ### Fixes diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 5ebac698175..f320be3b771 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -16,6 +16,7 @@ OpenClaw can run agents in isolated sandbox runtimes for security. The `sandbox` Today that usually means: - Docker sandbox containers +- SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"` - OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` ## Commands @@ -97,6 +98,22 @@ openclaw sandbox recreate --all openclaw sandbox recreate --all ``` +### After changing SSH target or SSH auth material + +```bash +# Edit config: +# - agents.defaults.sandbox.backend +# - agents.defaults.sandbox.ssh.target +# - agents.defaults.sandbox.ssh.workspaceRoot +# - agents.defaults.sandbox.ssh.identityFile / certificateFile / knownHostsFile +# - agents.defaults.sandbox.ssh.identityData / certificateData / knownHostsData + +openclaw sandbox recreate --all +``` + +For the core `ssh` backend, recreate deletes the per-scope remote workspace root +on the SSH target. The next run seeds it again from the local workspace. + ### After changing OpenShell source, policy, or mode ```bash @@ -150,7 +167,7 @@ Sandbox settings live in `~/.openclaw/openclaw.json` under `agents.defaults.sand "defaults": { "sandbox": { "mode": "all", // off, non-main, all - "backend": "docker", // docker, openshell + "backend": "docker", // docker, ssh, openshell "scope": "agent", // session, agent, shared "docker": { "image": "openclaw-sandbox:bookworm-slim", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 951f99f1165..ecefd8bbc4e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1125,7 +1125,7 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing defaults: { sandbox: { mode: "non-main", // off | non-main | all - backend: "docker", // docker | openshell + backend: "docker", // docker | ssh | openshell scope: "agent", // session | agent | shared workspaceAccess: "none", // none | ro | rw workspaceRoot: "~/.openclaw/sandboxes", @@ -1154,6 +1154,20 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing extraHosts: ["internal.service:10.0.0.5"], binds: ["/home/user/source:/source:rw"], }, + ssh: { + target: "user@gateway-host:22", + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + identityFile: "~/.ssh/id_ed25519", + certificateFile: "~/.ssh/id_ed25519-cert.pub", + knownHostsFile: "~/.ssh/known_hosts", + // SecretRefs / inline contents also supported: + // identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + // certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + // knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, browser: { enabled: false, image: "openclaw-sandbox-browser:bookworm-slim", @@ -1203,11 +1217,29 @@ Optional sandboxing for the embedded agent. See [Sandboxing](/gateway/sandboxing **Backend:** - `docker`: local Docker runtime (default) +- `ssh`: generic SSH-backed remote runtime - `openshell`: OpenShell runtime When `backend: "openshell"` is selected, runtime-specific settings move to `plugins.entries.openshell.config`. +**SSH backend config:** + +- `target`: SSH target in `user@host[:port]` form +- `command`: SSH client command (default: `ssh`) +- `workspaceRoot`: absolute remote root used for per-scope workspaces +- `identityFile` / `certificateFile` / `knownHostsFile`: existing local files passed to OpenSSH +- `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime +- `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs + +**SSH backend behavior:** + +- seeds the remote workspace once after create or recreate +- then keeps the remote SSH workspace canonical +- routes `exec`, file tools, and media paths over SSH +- does not sync remote changes back to the host automatically +- does not support sandbox browser containers + **Workspace access:** - `none`: per-scope sandbox workspace under `~/.openclaw/sandboxes` @@ -1252,6 +1284,7 @@ When `backend: "openshell"` is selected, runtime-specific settings move to - `remote`: seed remote once when the sandbox is created, then keep the remote workspace canonical In `remote` mode, host-local edits made outside OpenClaw are not synced into the sandbox automatically after the seed step. +Transport is SSH into the OpenShell sandbox, but the plugin owns sandbox lifecycle and optional mirror sync. **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index db40b802832..b37757334c0 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -59,10 +59,61 @@ Not sandboxed: `agents.defaults.sandbox.backend` controls **which runtime** provides the sandbox: - `"docker"` (default): local Docker-backed sandbox runtime. +- `"ssh"`: generic SSH-backed remote sandbox runtime. - `"openshell"`: OpenShell-backed sandbox runtime. +SSH-specific config lives under `agents.defaults.sandbox.ssh`. OpenShell-specific config lives under `plugins.entries.openshell.config`. +### SSH backend + +Use `backend: "ssh"` when you want OpenClaw to sandbox `exec`, file tools, and media reads on +an arbitrary SSH-accessible machine. + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "user@gateway-host:22", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + identityFile: "~/.ssh/id_ed25519", + certificateFile: "~/.ssh/id_ed25519-cert.pub", + knownHostsFile: "~/.ssh/known_hosts", + // Or use SecretRefs / inline contents instead of local files: + // identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + // certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + // knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + }, + }, + }, +} +``` + +How it works: + +- OpenClaw creates a per-scope remote root under `sandbox.ssh.workspaceRoot`. +- On first use after create or recreate, OpenClaw seeds that remote workspace from the local workspace once. +- After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. +- OpenClaw does not sync remote changes back to the local workspace automatically. + +This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed. + +Important consequences: + +- Host-local edits made outside OpenClaw after the seed step are not visible remotely until you recreate the sandbox. +- `openclaw sandbox recreate` deletes the per-scope remote root and seeds again from local on next use. +- Browser sandboxing is not supported on the SSH backend. +- `sandbox.docker.*` settings do not apply to the SSH backend. + ```json5 { agents: { @@ -96,6 +147,9 @@ OpenShell modes: - `mirror` (default): local workspace stays canonical. OpenClaw syncs local files into OpenShell before exec and syncs the remote workspace back after exec. - `remote`: OpenShell workspace is canonical after the sandbox is created. OpenClaw seeds the remote workspace once from the local workspace, then file tools and exec run directly against the remote sandbox without syncing changes back. +OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend. +The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode. + Current OpenShell limitations: - sandbox browser is not supported yet @@ -136,6 +190,7 @@ Behavior: - After that, `exec`, `read`, `write`, `edit`, and `apply_patch` operate directly against the remote OpenShell workspace. - OpenClaw does **not** sync remote changes back into the local workspace after exec. - Prompt-time media reads still work because file and media tools read through the sandbox bridge instead of assuming a local host path. +- Transport is SSH into the OpenShell sandbox returned by `openshell sandbox ssh-config`. Important consequences: diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 379e4a527d4..eb044eaf03c 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -41,6 +41,9 @@ Examples of inactive surfaces: - Web search provider-specific keys that are not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. After selection, non-selected provider keys are treated as inactive until selected. +- Sandbox SSH auth material (`agents.defaults.sandbox.ssh.identityData`, + `certificateData`, `knownHostsData`, plus per-agent overrides) is active only + when the effective sandbox backend is `ssh` for the default agent or an enabled agent. - `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true: - `gateway.mode=remote` - `gateway.remote.url` is configured diff --git a/extensions/openshell/src/backend.test.ts b/extensions/openshell/src/backend.test.ts index 2999599c648..2685d7effa8 100644 --- a/extensions/openshell/src/backend.test.ts +++ b/extensions/openshell/src/backend.test.ts @@ -101,6 +101,7 @@ describe("openshell backend manager", () => { image: "openclaw", configLabelKind: "Source", }, + config: {}, }); expect(cliMocks.runOpenShellCli).toHaveBeenCalledWith({ diff --git a/extensions/openshell/src/backend.ts b/extensions/openshell/src/backend.ts index 85c3d415904..d87b1c92af8 100644 --- a/extensions/openshell/src/backend.ts +++ b/extensions/openshell/src/backend.ts @@ -4,43 +4,44 @@ import path from "node:path"; import type { CreateSandboxBackendParams, OpenClawConfig, + RemoteShellSandboxHandle, SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendFactory, SandboxBackendHandle, SandboxBackendManager, + SshSandboxSession, +} from "openclaw/plugin-sdk/core"; +import { + createRemoteShellSandboxFsBridge, + disposeSshSandboxSession, + resolvePreferredOpenClawTmpDir, + runSshSandboxCommand, } from "openclaw/plugin-sdk/core"; -import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/core"; import { buildExecRemoteCommand, buildRemoteCommand, createOpenShellSshSession, - disposeOpenShellSshSession, runOpenShellCli, - runOpenShellSshCommand, type OpenShellExecContext, - type OpenShellSshSession, } from "./cli.js"; import { resolveOpenShellPluginConfig, type ResolvedOpenShellPluginConfig } from "./config.js"; import { createOpenShellFsBridge } from "./fs-bridge.js"; import { replaceDirectoryContents } from "./mirror.js"; -import { createOpenShellRemoteFsBridge } from "./remote-fs-bridge.js"; type CreateOpenShellSandboxBackendFactoryParams = { pluginConfig: ResolvedOpenShellPluginConfig; }; type PendingExec = { - sshSession: OpenShellSshSession; + sshSession: SshSandboxSession; }; -export type OpenShellSandboxBackend = SandboxBackendHandle & { - mode: "mirror" | "remote"; - remoteWorkspaceDir: string; - remoteAgentWorkspaceDir: string; - runRemoteShellScript(params: SandboxBackendCommandParams): Promise; - syncLocalPathToRemote(localPath: string, remotePath: string): Promise; -}; +export type OpenShellSandboxBackend = SandboxBackendHandle & + RemoteShellSandboxHandle & { + mode: "mirror" | "remote"; + syncLocalPathToRemote(localPath: string, remotePath: string): Promise; + }; export function createOpenShellSandboxBackendFactory( params: CreateOpenShellSandboxBackendFactoryParams, @@ -129,9 +130,9 @@ async function createOpenShellSandboxBackend(params: { runShellCommand: async (command) => await impl.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => params.pluginConfig.mode === "remote" - ? createOpenShellRemoteFsBridge({ + ? createRemoteShellSandboxFsBridge({ sandbox, - backend: impl.asHandle(), + runtime: impl.asHandle(), }) : createOpenShellFsBridge({ sandbox, @@ -186,9 +187,9 @@ class OpenShellSandboxBackendImpl { runShellCommand: async (command) => await self.runRemoteShellScript(command), createFsBridge: ({ sandbox }) => this.params.execContext.config.mode === "remote" - ? createOpenShellRemoteFsBridge({ + ? createRemoteShellSandboxFsBridge({ sandbox, - backend: self.asHandle(), + runtime: self.asHandle(), }) : createOpenShellFsBridge({ sandbox, @@ -242,7 +243,7 @@ class OpenShellSandboxBackendImpl { } } finally { if (token?.sshSession) { - await disposeOpenShellSshSession(token.sshSession); + await disposeSshSandboxSession(token.sshSession); } } } @@ -262,7 +263,7 @@ class OpenShellSandboxBackendImpl { context: this.params.execContext, }); try { - return await runOpenShellSshCommand({ + return await runSshSandboxCommand({ session, remoteCommand: buildRemoteCommand([ "/bin/sh", @@ -276,7 +277,7 @@ class OpenShellSandboxBackendImpl { signal: params.signal, }); } finally { - await disposeOpenShellSshSession(session); + await disposeSshSandboxSession(session); } } diff --git a/extensions/openshell/src/cli.ts b/extensions/openshell/src/cli.ts index 8f9808b5164..411166520e7 100644 --- a/extensions/openshell/src/cli.ts +++ b/extensions/openshell/src/cli.ts @@ -1,34 +1,20 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { - resolvePreferredOpenClawTmpDir, + buildExecRemoteCommand, + createSshSandboxSessionFromConfigText, runPluginCommandWithTimeout, + shellEscape, + type SshSandboxSession, } from "openclaw/plugin-sdk/core"; -import type { SandboxBackendCommandResult } from "openclaw/plugin-sdk/core"; import type { ResolvedOpenShellPluginConfig } from "./config.js"; +export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core"; + export type OpenShellExecContext = { config: ResolvedOpenShellPluginConfig; sandboxName: string; timeoutMs?: number; }; -export type OpenShellSshSession = { - configPath: string; - host: string; -}; - -export type OpenShellRunSshCommandParams = { - session: OpenShellSshSession; - remoteCommand: string; - stdin?: Buffer | string; - allowFailure?: boolean; - signal?: AbortSignal; - tty?: boolean; -}; - export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): string[] { const argv = [config.command]; if (config.gateway) { @@ -40,10 +26,6 @@ export function buildOpenShellBaseArgv(config: ResolvedOpenShellPluginConfig): s return argv; } -export function shellEscape(value: string): string { - return `'${value.replaceAll("'", `'\"'\"'`)}'`; -} - export function buildRemoteCommand(argv: string[]): string { return argv.map((entry) => shellEscape(entry)).join(" "); } @@ -64,7 +46,7 @@ export async function runOpenShellCli(params: { export async function createOpenShellSshSession(params: { context: OpenShellExecContext; -}): Promise { +}): Promise { const result = await runOpenShellCli({ context: params.context, args: ["sandbox", "ssh-config", params.context.sandboxName], @@ -72,95 +54,7 @@ export async function createOpenShellSshSession(params: { if (result.code !== 0) { throw new Error(result.stderr.trim() || "openshell sandbox ssh-config failed"); } - const hostMatch = result.stdout.match(/^\s*Host\s+(\S+)/m); - const host = hostMatch?.[1]?.trim(); - if (!host) { - throw new Error("Failed to parse openshell ssh-config output."); - } - const tmpRoot = resolvePreferredOpenClawTmpDir() || os.tmpdir(); - await fs.mkdir(tmpRoot, { recursive: true }); - const configDir = await fs.mkdtemp(path.join(tmpRoot, "openclaw-openshell-ssh-")); - const configPath = path.join(configDir, "config"); - await fs.writeFile(configPath, result.stdout, "utf8"); - return { configPath, host }; -} - -export async function disposeOpenShellSshSession(session: OpenShellSshSession): Promise { - await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); -} - -export async function runOpenShellSshCommand( - params: OpenShellRunSshCommandParams, -): Promise { - const argv = [ - "ssh", - "-F", - params.session.configPath, - ...(params.tty - ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] - : ["-T", "-o", "RequestTTY=no"]), - params.session.host, - params.remoteCommand, - ]; - - const result = await new Promise((resolve, reject) => { - const child = spawn(argv[0]!, argv.slice(1), { - stdio: ["pipe", "pipe", "pipe"], - env: process.env, - signal: params.signal, - }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); - child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); - child.on("error", reject); - child.on("close", (code) => { - const stdout = Buffer.concat(stdoutChunks); - const stderr = Buffer.concat(stderrChunks); - const exitCode = code ?? 0; - if (exitCode !== 0 && !params.allowFailure) { - const error = Object.assign( - new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), - { - code: exitCode, - stdout, - stderr, - }, - ); - reject(error); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - - if (params.stdin !== undefined) { - child.stdin.end(params.stdin); - return; - } - child.stdin.end(); + return await createSshSandboxSessionFromConfigText({ + configText: result.stdout, }); - - return result; -} - -export function buildExecRemoteCommand(params: { - command: string; - workdir?: string; - env: Record; -}): string { - const body = params.workdir - ? `cd ${shellEscape(params.workdir)} && ${params.command}` - : params.command; - const argv = - Object.keys(params.env).length > 0 - ? [ - "env", - ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), - "/bin/sh", - "-c", - body, - ] - : ["/bin/sh", "-c", body]; - return buildRemoteCommand(argv); } diff --git a/extensions/openshell/src/remote-fs-bridge.ts b/extensions/openshell/src/remote-fs-bridge.ts index 3560fa78f28..9cc1ddf704d 100644 --- a/extensions/openshell/src/remote-fs-bridge.ts +++ b/extensions/openshell/src/remote-fs-bridge.ts @@ -1,550 +1,16 @@ -import path from "node:path"; -import type { - SandboxContext, - SandboxFsBridge, - SandboxFsStat, - SandboxResolvedPath, +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, + type SandboxContext, + type SandboxFsBridge, } from "openclaw/plugin-sdk/core"; -import { SANDBOX_PINNED_MUTATION_PYTHON } from "../../../src/agents/sandbox/fs-bridge-mutation-helper.js"; -import type { OpenShellSandboxBackend } from "./backend.js"; - -type ResolvedRemotePath = SandboxResolvedPath & { - writable: boolean; - mountRootPath: string; - source: "workspace" | "agent"; -}; - -type MountInfo = { - containerRoot: string; - writable: boolean; - source: "workspace" | "agent"; -}; export function createOpenShellRemoteFsBridge(params: { sandbox: SandboxContext; - backend: OpenShellSandboxBackend; + backend: RemoteShellSandboxHandle; }): SandboxFsBridge { - return new OpenShellRemoteFsBridge(params.sandbox, params.backend); -} - -class OpenShellRemoteFsBridge implements SandboxFsBridge { - constructor( - private readonly sandbox: SandboxContext, - private readonly backend: OpenShellSandboxBackend, - ) {} - - resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { - const target = this.resolveTarget(params); - return { - relativePath: target.relativePath, - containerPath: target.containerPath, - }; - } - - async readFile(params: { - filePath: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - const canonical = await this.resolveCanonicalPath({ - containerPath: target.containerPath, - action: "read files", - }); - await this.assertNoHardlinkedFile({ - containerPath: canonical, - action: "read files", - signal: params.signal, - }); - const result = await this.runRemoteScript({ - script: 'set -eu\ncat -- "$1"', - args: [canonical], - signal: params.signal, - }); - return result.stdout; - } - - async writeFile(params: { - filePath: string; - cwd?: string; - data: Buffer | string; - encoding?: BufferEncoding; - mkdir?: boolean; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "write files"); - const pinned = await this.resolvePinnedParent({ - containerPath: target.containerPath, - action: "write files", - requireWritable: true, - }); - await this.assertNoHardlinkedFile({ - containerPath: target.containerPath, - action: "write files", - signal: params.signal, - }); - const buffer = Buffer.isBuffer(params.data) - ? params.data - : Buffer.from(params.data, params.encoding ?? "utf8"); - await this.runMutation({ - args: [ - "write", - pinned.mountRootPath, - pinned.relativeParentPath, - pinned.basename, - params.mkdir !== false ? "1" : "0", - ], - stdin: buffer, - signal: params.signal, - }); - } - - async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "create directories"); - const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); - if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, - ); - } - await this.runMutation({ - args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], - signal: params.signal, - }); - } - - async remove(params: { - filePath: string; - cwd?: string; - recursive?: boolean; - force?: boolean; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - this.ensureWritable(target, "remove files"); - const exists = await this.remotePathExists(target.containerPath, params.signal); - if (!exists) { - if (params.force === false) { - throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); - } - return; - } - const pinned = await this.resolvePinnedParent({ - containerPath: target.containerPath, - action: "remove files", - requireWritable: true, - allowFinalSymlinkForUnlink: true, - }); - await this.runMutation({ - args: [ - "remove", - pinned.mountRootPath, - pinned.relativeParentPath, - pinned.basename, - params.recursive ? "1" : "0", - params.force === false ? "0" : "1", - ], - signal: params.signal, - allowFailure: params.force !== false, - }); - } - - async rename(params: { - from: string; - to: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); - const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); - this.ensureWritable(from, "rename files"); - this.ensureWritable(to, "rename files"); - const fromPinned = await this.resolvePinnedParent({ - containerPath: from.containerPath, - action: "rename files", - requireWritable: true, - allowFinalSymlinkForUnlink: true, - }); - const toPinned = await this.resolvePinnedParent({ - containerPath: to.containerPath, - action: "rename files", - requireWritable: true, - }); - await this.runMutation({ - args: [ - "rename", - fromPinned.mountRootPath, - fromPinned.relativeParentPath, - fromPinned.basename, - toPinned.mountRootPath, - toPinned.relativeParentPath, - toPinned.basename, - "1", - ], - signal: params.signal, - }); - } - - async stat(params: { - filePath: string; - cwd?: string; - signal?: AbortSignal; - }): Promise { - const target = this.resolveTarget(params); - const exists = await this.remotePathExists(target.containerPath, params.signal); - if (!exists) { - return null; - } - const canonical = await this.resolveCanonicalPath({ - containerPath: target.containerPath, - action: "stat files", - signal: params.signal, - }); - await this.assertNoHardlinkedFile({ - containerPath: canonical, - action: "stat files", - signal: params.signal, - }); - const result = await this.runRemoteScript({ - script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', - args: [canonical], - signal: params.signal, - }); - const output = result.stdout.toString("utf8").trim(); - const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); - return { - type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", - size: Number(sizeRaw), - mtimeMs: Number(mtimeRaw) * 1000, - }; - } - - private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { - const workspaceRoot = path.resolve(this.sandbox.workspaceDir); - const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); - const workspaceContainerRoot = normalizeContainerPath(this.backend.remoteWorkspaceDir); - const agentContainerRoot = normalizeContainerPath(this.backend.remoteAgentWorkspaceDir); - const mounts: MountInfo[] = [ - { - containerRoot: workspaceContainerRoot, - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ]; - if ( - this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ) { - mounts.push({ - containerRoot: agentContainerRoot, - writable: this.sandbox.workspaceAccess === "rw", - source: "agent", - }); - } - - const input = params.filePath.trim(); - const inputPosix = input.replace(/\\/g, "/"); - const maybeContainerMount = path.posix.isAbsolute(inputPosix) - ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) - : null; - if (maybeContainerMount) { - return this.toResolvedPath({ - mount: maybeContainerMount, - containerPath: normalizeContainerPath(inputPosix), - }); - } - - const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; - const hostCandidate = path.isAbsolute(input) - ? path.resolve(input) - : path.resolve(hostCwd, input); - if (isPathInside(workspaceRoot, hostCandidate)) { - const relative = toPosixRelative(workspaceRoot, hostCandidate); - return this.toResolvedPath({ - mount: mounts[0]!, - containerPath: relative - ? path.posix.join(workspaceContainerRoot, relative) - : workspaceContainerRoot, - }); - } - if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { - const relative = toPosixRelative(agentRoot, hostCandidate); - return this.toResolvedPath({ - mount: mounts[1], - containerPath: relative - ? path.posix.join(agentContainerRoot, relative) - : agentContainerRoot, - }); - } - - if (params.cwd) { - const cwdPosix = params.cwd.replace(/\\/g, "/"); - if (path.posix.isAbsolute(cwdPosix)) { - const cwdContainer = normalizeContainerPath(cwdPosix); - const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); - if (cwdMount) { - return this.toResolvedPath({ - mount: cwdMount, - containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), - }); - } - } - } - - throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); - } - - private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { - const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); - if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, - ); - } - return { - relativePath: - params.mount.source === "workspace" - ? relative === "." - ? "" - : relative - : relative === "." - ? params.mount.containerRoot - : `${params.mount.containerRoot}/${relative}`, - containerPath: params.containerPath, - writable: params.mount.writable, - mountRootPath: params.mount.containerRoot, - source: params.mount.source, - }; - } - - private resolveMountByContainerPath( - mounts: MountInfo[], - containerPath: string, - ): MountInfo | null { - const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); - for (const mount of ordered) { - if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { - return mount; - } - } - return null; - } - - private ensureWritable(target: ResolvedRemotePath, action: string) { - if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { - throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); - } - } - - private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { - const result = await this.runRemoteScript({ - script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', - args: [containerPath], - signal, - }); - return result.stdout.toString("utf8").trim() === "1"; - } - - private async resolveCanonicalPath(params: { - containerPath: string; - action: string; - allowFinalSymlinkForUnlink?: boolean; - signal?: AbortSignal; - }): Promise { - const script = [ - "set -eu", - 'target="$1"', - 'allow_final="$2"', - 'suffix=""', - 'probe="$target"', - 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', - 'cursor="$probe"', - 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', - ' parent=$(dirname -- "$cursor")', - ' if [ "$parent" = "$cursor" ]; then break; fi', - ' base=$(basename -- "$cursor")', - ' suffix="/$base$suffix"', - ' cursor="$parent"', - "done", - 'canonical=$(readlink -f -- "$cursor")', - 'printf "%s%s\\n" "$canonical" "$suffix"', - ].join("\n"); - const result = await this.runRemoteScript({ - script, - args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], - signal: params.signal, - }); - const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); - const mount = this.resolveMountByContainerPath( - [ - { - containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ...(this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ? [ - { - containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "agent" as const, - }, - ] - : []), - ], - canonical, - ); - if (!mount) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - return canonical; - } - - private async assertNoHardlinkedFile(params: { - containerPath: string; - action: string; - signal?: AbortSignal; - }): Promise { - const result = await this.runRemoteScript({ - script: [ - 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', - 'stats=$(stat -c "%F|%h" -- "$1")', - 'printf "%s\\n" "$stats"', - ].join("\n"), - args: [params.containerPath], - signal: params.signal, - allowFailure: true, - }); - const output = result.stdout.toString("utf8").trim(); - if (!output) { - return; - } - const [kind = "", linksRaw = "1"] = output.split("|"); - if (kind === "regular file" && Number(linksRaw) > 1) { - throw new Error( - `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, - ); - } - } - - private async resolvePinnedParent(params: { - containerPath: string; - action: string; - requireWritable?: boolean; - allowFinalSymlinkForUnlink?: boolean; - }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { - const basename = path.posix.basename(params.containerPath); - if (!basename || basename === "." || basename === "/") { - throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); - } - const canonicalParent = await this.resolveCanonicalPath({ - containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), - action: params.action, - allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, - }); - const mount = this.resolveMountByContainerPath( - [ - { - containerRoot: normalizeContainerPath(this.backend.remoteWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "workspace", - }, - ...(this.sandbox.workspaceAccess !== "none" && - path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) - ? [ - { - containerRoot: normalizeContainerPath(this.backend.remoteAgentWorkspaceDir), - writable: this.sandbox.workspaceAccess === "rw", - source: "agent" as const, - }, - ] - : []), - ], - canonicalParent, - ); - if (!mount) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - if (params.requireWritable && !mount.writable) { - throw new Error( - `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, - ); - } - const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); - if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { - throw new Error( - `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, - ); - } - return { - mountRootPath: mount.containerRoot, - relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, - basename, - }; - } - - private async runMutation(params: { - args: string[]; - stdin?: Buffer | string; - signal?: AbortSignal; - allowFailure?: boolean; - }) { - await this.runRemoteScript({ - script: [ - "set -eu", - "python3 /dev/fd/3 \"$@\" 3<<'PY'", - SANDBOX_PINNED_MUTATION_PYTHON, - "PY", - ].join("\n"), - args: params.args, - stdin: params.stdin, - signal: params.signal, - allowFailure: params.allowFailure, - }); - } - - private async runRemoteScript(params: { - script: string; - args?: string[]; - stdin?: Buffer | string; - signal?: AbortSignal; - allowFailure?: boolean; - }) { - return await this.backend.runRemoteShellScript({ - script: params.script, - args: params.args, - stdin: params.stdin, - signal: params.signal, - allowFailure: params.allowFailure, - }); - } -} - -function normalizeContainerPath(value: string): string { - const normalized = path.posix.normalize(value.trim() || "/"); - return normalized.startsWith("/") ? normalized : `/${normalized}`; -} - -function isPathInsideContainerRoot(root: string, candidate: string): boolean { - const normalizedRoot = normalizeContainerPath(root); - const normalizedCandidate = normalizeContainerPath(candidate); - return ( - normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) - ); -} - -function isPathInside(root: string, candidate: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function toPosixRelative(root: string, candidate: string): string { - return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); + return createRemoteShellSandboxFsBridge({ + sandbox: params.sandbox, + runtime: params.backend, + }); } diff --git a/src/agents/sandbox-merge.test.ts b/src/agents/sandbox-merge.test.ts index d120ac84820..742701017d2 100644 --- a/src/agents/sandbox-merge.test.ts +++ b/src/agents/sandbox-merge.test.ts @@ -5,6 +5,7 @@ import { resolveSandboxDockerConfig, resolveSandboxPruneConfig, resolveSandboxScope, + resolveSandboxSshConfig, } from "./sandbox/config.js"; describe("sandbox config merges", () => { @@ -130,6 +131,41 @@ describe("sandbox config merges", () => { expect(pruneShared).toEqual({ idleHours: 24, maxAgeDays: 7 }); }); + it("merges sandbox ssh settings and ignores agent overrides under shared scope", () => { + const ssh = resolveSandboxSshConfig({ + scope: "agent", + globalSsh: { + target: "global@example.com:22", + command: "ssh", + identityFile: "~/.ssh/global", + strictHostKeyChecking: true, + }, + agentSsh: { + target: "agent@example.com:2222", + certificateFile: "~/.ssh/agent-cert.pub", + strictHostKeyChecking: false, + }, + }); + expect(ssh).toMatchObject({ + target: "agent@example.com:2222", + command: "ssh", + identityFile: "~/.ssh/global", + certificateFile: "~/.ssh/agent-cert.pub", + strictHostKeyChecking: false, + }); + + const sshShared = resolveSandboxSshConfig({ + scope: "shared", + globalSsh: { + target: "global@example.com:22", + }, + agentSsh: { + target: "agent@example.com:2222", + }, + }); + expect(sshShared.target).toBe("global@example.com:22"); + }); + it("defaults sandbox backend to docker", () => { expect(resolveSandboxConfigForAgent().backend).toBe("docker"); }); diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index b52cb5ab050..d26dc75204d 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -34,6 +34,18 @@ export { export { resolveSandboxToolPolicyForAgent } from "./sandbox/tool-policy.js"; export type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./sandbox/fs-bridge.js"; +export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, +} from "./sandbox/ssh.js"; +export { createRemoteShellSandboxFsBridge } from "./sandbox/remote-fs-bridge.js"; export type { CreateSandboxBackendParams, @@ -47,6 +59,12 @@ export type { SandboxBackendRegistration, SandboxBackendRuntimeInfo, } from "./sandbox/backend.js"; +export type { RemoteShellSandboxHandle } from "./sandbox/remote-fs-bridge.js"; +export type { + RunSshSandboxCommandParams, + SshSandboxSession, + SshSandboxSettings, +} from "./sandbox/ssh.js"; export type { SandboxBrowserConfig, @@ -56,6 +74,7 @@ export type { SandboxDockerConfig, SandboxPruneConfig, SandboxScope, + SandboxSshConfig, SandboxToolPolicy, SandboxToolPolicyResolved, SandboxToolPolicySource, diff --git a/src/agents/sandbox/backend.ts b/src/agents/sandbox/backend.ts index c186b0fe4cc..013cb565176 100644 --- a/src/agents/sandbox/backend.ts +++ b/src/agents/sandbox/backend.ts @@ -65,7 +65,11 @@ export type SandboxBackendManager = { config: OpenClawConfig; agentId?: string; }): Promise; - removeRuntime(params: { entry: SandboxRegistryEntry }): Promise; + removeRuntime(params: { + entry: SandboxRegistryEntry; + config: OpenClawConfig; + agentId?: string; + }): Promise; }; export type CreateSandboxBackendParams = { @@ -141,8 +145,14 @@ export function requireSandboxBackendFactory(id: string): SandboxBackendFactory } import { createDockerSandboxBackend, dockerSandboxBackendManager } from "./docker-backend.js"; +import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; registerSandboxBackend("docker", { factory: createDockerSandboxBackend, manager: dockerSandboxBackendManager, }); + +registerSandboxBackend("ssh", { + factory: createSshSandboxBackend, + manager: sshSandboxBackendManager, +}); diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index c62276c6b87..88b5feccccc 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -62,6 +62,12 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig { capDrop: ["ALL"], env: { LANG: "C.UTF-8" }, }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, browser: { enabled: true, image: "openclaw-sandbox-browser:bookworm-slim", diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index dda3e048ea7..c5bd29e9d11 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -1,4 +1,6 @@ import type { OpenClawConfig } from "../../config/config.js"; +import type { SandboxSshSettings } from "../../config/types.sandbox.js"; +import { normalizeSecretInputString } from "../../config/types.secrets.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, @@ -22,6 +24,7 @@ import type { SandboxDockerConfig, SandboxPruneConfig, SandboxScope, + SandboxSshConfig, } from "./types.js"; export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ @@ -30,6 +33,9 @@ export const DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS = [ "dangerouslyAllowContainerNamespaceJoin", ] as const; +const DEFAULT_SANDBOX_SSH_COMMAND = "ssh"; +const DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT = "/tmp/openclaw-sandboxes"; + type DangerousSandboxDockerBooleanKey = (typeof DANGEROUS_SANDBOX_DOCKER_BOOLEAN_KEYS)[number]; type DangerousSandboxDockerBooleans = Pick; @@ -167,6 +173,54 @@ export function resolveSandboxPruneConfig(params: { }; } +function normalizeOptionalString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeRemoteRoot(value: string | undefined, fallback: string): string { + const normalized = normalizeOptionalString(value) ?? fallback; + const posix = normalized.replaceAll("\\", "/"); + if (!posix.startsWith("/")) { + throw new Error(`Sandbox SSH workspaceRoot must be an absolute POSIX path: ${normalized}`); + } + return posix.replace(/\/+$/g, "") || "/"; +} + +export function resolveSandboxSshConfig(params: { + scope: SandboxScope; + globalSsh?: Partial; + agentSsh?: Partial; +}): SandboxSshConfig { + const agentSsh = params.scope === "shared" ? undefined : params.agentSsh; + const globalSsh = params.globalSsh; + return { + target: normalizeOptionalString(agentSsh?.target ?? globalSsh?.target), + command: + normalizeOptionalString(agentSsh?.command ?? globalSsh?.command) ?? + DEFAULT_SANDBOX_SSH_COMMAND, + workspaceRoot: normalizeRemoteRoot( + agentSsh?.workspaceRoot ?? globalSsh?.workspaceRoot, + DEFAULT_SANDBOX_SSH_WORKSPACE_ROOT, + ), + strictHostKeyChecking: + agentSsh?.strictHostKeyChecking ?? globalSsh?.strictHostKeyChecking ?? true, + updateHostKeys: agentSsh?.updateHostKeys ?? globalSsh?.updateHostKeys ?? true, + identityFile: normalizeOptionalString(agentSsh?.identityFile ?? globalSsh?.identityFile), + certificateFile: normalizeOptionalString( + agentSsh?.certificateFile ?? globalSsh?.certificateFile, + ), + knownHostsFile: normalizeOptionalString(agentSsh?.knownHostsFile ?? globalSsh?.knownHostsFile), + identityData: normalizeSecretInputString(agentSsh?.identityData ?? globalSsh?.identityData), + certificateData: normalizeSecretInputString( + agentSsh?.certificateData ?? globalSsh?.certificateData, + ), + knownHostsData: normalizeSecretInputString( + agentSsh?.knownHostsData ?? globalSsh?.knownHostsData, + ), + }; +} + export function resolveSandboxConfigForAgent( cfg?: OpenClawConfig, agentId?: string, @@ -199,6 +253,11 @@ export function resolveSandboxConfigForAgent( globalDocker: agent?.docker, agentDocker: agentSandbox?.docker, }), + ssh: resolveSandboxSshConfig({ + scope, + globalSsh: agent?.ssh, + agentSsh: agentSandbox?.ssh, + }), browser: resolveSandboxBrowserConfig({ scope, globalBrowser: agent?.browser, diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 54941ba04d1..46d37f9fd61 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -109,6 +109,12 @@ function createSandboxConfig( binds: binds ?? ["/tmp/workspace:/workspace:rw"], dangerouslyAllowReservedContainerTargets: true, }, + ssh: { + command: "ssh", + workspaceRoot: "/tmp/openclaw-sandboxes", + strictHostKeyChecking: true, + updateHostKeys: true, + }, browser: { enabled: false, image: "openclaw-browser:test", diff --git a/src/agents/sandbox/manage.ts b/src/agents/sandbox/manage.ts index 0b5ba578d7d..c6e6f3fd7bf 100644 --- a/src/agents/sandbox/manage.ts +++ b/src/agents/sandbox/manage.ts @@ -85,16 +85,22 @@ export async function listSandboxBrowsers(): Promise { } export async function removeSandboxContainer(containerName: string): Promise { + const config = loadConfig(); const registry = await readRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { const manager = getSandboxBackendManager(entry.backendId ?? "docker"); - await manager?.removeRuntime({ entry }); + await manager?.removeRuntime({ + entry, + config, + agentId: resolveSandboxAgentId(entry.sessionKey), + }); } await removeRegistryEntry(containerName); } export async function removeSandboxBrowserContainer(containerName: string): Promise { + const config = loadConfig(); const registry = await readBrowserRegistry(); const entry = registry.entries.find((item) => item.containerName === containerName); if (entry) { @@ -105,6 +111,7 @@ export async function removeSandboxBrowserContainer(containerName: string): Prom runtimeLabel: entry.containerName, configLabelKind: "Image", }, + config, }); } await removeBrowserRegistryEntry(containerName); diff --git a/src/agents/sandbox/prune.ts b/src/agents/sandbox/prune.ts index 6ccfd8ac238..8005c23330e 100644 --- a/src/agents/sandbox/prune.ts +++ b/src/agents/sandbox/prune.ts @@ -1,4 +1,5 @@ import { stopBrowserBridgeServer } from "../../browser/bridge-server.js"; +import { loadConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; import { getSandboxBackendManager } from "./backend.js"; import { BROWSER_BRIDGES } from "./browser-bridges.js"; @@ -62,18 +63,23 @@ async function pruneSandboxRegistryEntries( } async function pruneSandboxContainers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries({ cfg, read: readRegistry, remove: removeRegistryEntry, removeRuntime: async (entry) => { const manager = getSandboxBackendManager(entry.backendId ?? "docker"); - await manager?.removeRuntime({ entry }); + await manager?.removeRuntime({ + entry, + config, + }); }, }); } async function pruneSandboxBrowsers(cfg: SandboxConfig) { + const config = loadConfig(); await pruneSandboxRegistryEntries< SandboxBrowserRegistryEntry & { backendId?: string; @@ -92,6 +98,7 @@ async function pruneSandboxBrowsers(cfg: SandboxConfig) { runtimeLabel: entry.containerName, configLabelKind: "Image", }, + config, }); }, onRemoved: async (entry) => { diff --git a/src/agents/sandbox/remote-fs-bridge.ts b/src/agents/sandbox/remote-fs-bridge.ts new file mode 100644 index 00000000000..ef70e928eac --- /dev/null +++ b/src/agents/sandbox/remote-fs-bridge.ts @@ -0,0 +1,518 @@ +import path from "node:path"; +import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js"; +import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js"; +import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js"; +import type { SandboxContext } from "./types.js"; + +type ResolvedRemotePath = SandboxResolvedPath & { + writable: boolean; + mountRootPath: string; + source: "workspace" | "agent"; +}; + +type MountInfo = { + containerRoot: string; + writable: boolean; + source: "workspace" | "agent"; +}; + +export type RemoteShellSandboxHandle = { + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; + runRemoteShellScript(params: SandboxBackendCommandParams): Promise; +}; + +export function createRemoteShellSandboxFsBridge(params: { + sandbox: SandboxContext; + runtime: RemoteShellSandboxHandle; +}): SandboxFsBridge { + return new RemoteShellSandboxFsBridge(params.sandbox, params.runtime); +} + +class RemoteShellSandboxFsBridge implements SandboxFsBridge { + constructor( + private readonly sandbox: SandboxContext, + private readonly runtime: RemoteShellSandboxHandle, + ) {} + + resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath { + const target = this.resolveTarget(params); + return { + relativePath: target.relativePath, + containerPath: target.containerPath, + }; + } + + async readFile(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "read files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "read files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\ncat -- "$1"', + args: [canonical], + signal: params.signal, + }); + return result.stdout; + } + + async writeFile(params: { + filePath: string; + cwd?: string; + data: Buffer | string; + encoding?: BufferEncoding; + mkdir?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "write files"); + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "write files", + requireWritable: true, + }); + await this.assertNoHardlinkedFile({ + containerPath: target.containerPath, + action: "write files", + signal: params.signal, + }); + const buffer = Buffer.isBuffer(params.data) + ? params.data + : Buffer.from(params.data, params.encoding ?? "utf8"); + await this.runMutation({ + args: [ + "write", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.mkdir !== false ? "1" : "0", + ], + stdin: buffer, + signal: params.signal, + }); + } + + async mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "create directories"); + const relativePath = path.posix.relative(target.mountRootPath, target.containerPath); + if (relativePath.startsWith("..") || path.posix.isAbsolute(relativePath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot create directories: ${target.containerPath}`, + ); + } + await this.runMutation({ + args: ["mkdirp", target.mountRootPath, relativePath === "." ? "" : relativePath], + signal: params.signal, + }); + } + + async remove(params: { + filePath: string; + cwd?: string; + recursive?: boolean; + force?: boolean; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + this.ensureWritable(target, "remove files"); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + if (params.force === false) { + throw new Error(`Sandbox path not found; cannot remove files: ${target.containerPath}`); + } + return; + } + const pinned = await this.resolvePinnedParent({ + containerPath: target.containerPath, + action: "remove files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + await this.runMutation({ + args: [ + "remove", + pinned.mountRootPath, + pinned.relativeParentPath, + pinned.basename, + params.recursive ? "1" : "0", + params.force === false ? "0" : "1", + ], + signal: params.signal, + allowFailure: params.force !== false, + }); + } + + async rename(params: { + from: string; + to: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const from = this.resolveTarget({ filePath: params.from, cwd: params.cwd }); + const to = this.resolveTarget({ filePath: params.to, cwd: params.cwd }); + this.ensureWritable(from, "rename files"); + this.ensureWritable(to, "rename files"); + const fromPinned = await this.resolvePinnedParent({ + containerPath: from.containerPath, + action: "rename files", + requireWritable: true, + allowFinalSymlinkForUnlink: true, + }); + const toPinned = await this.resolvePinnedParent({ + containerPath: to.containerPath, + action: "rename files", + requireWritable: true, + }); + await this.runMutation({ + args: [ + "rename", + fromPinned.mountRootPath, + fromPinned.relativeParentPath, + fromPinned.basename, + toPinned.mountRootPath, + toPinned.relativeParentPath, + toPinned.basename, + "1", + ], + signal: params.signal, + }); + } + + async stat(params: { + filePath: string; + cwd?: string; + signal?: AbortSignal; + }): Promise { + const target = this.resolveTarget(params); + const exists = await this.remotePathExists(target.containerPath, params.signal); + if (!exists) { + return null; + } + const canonical = await this.resolveCanonicalPath({ + containerPath: target.containerPath, + action: "stat files", + signal: params.signal, + }); + await this.assertNoHardlinkedFile({ + containerPath: canonical, + action: "stat files", + signal: params.signal, + }); + const result = await this.runRemoteScript({ + script: 'set -eu\nstat -c "%F|%s|%Y" -- "$1"', + args: [canonical], + signal: params.signal, + }); + const output = result.stdout.toString("utf8").trim(); + const [kindRaw = "", sizeRaw = "0", mtimeRaw = "0"] = output.split("|"); + return { + type: kindRaw === "directory" ? "directory" : kindRaw === "regular file" ? "file" : "other", + size: Number(sizeRaw), + mtimeMs: Number(mtimeRaw) * 1000, + }; + } + + private getMounts(): MountInfo[] { + const mounts: MountInfo[] = [ + { + containerRoot: normalizeContainerPath(this.runtime.remoteWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "workspace", + }, + ]; + if ( + this.sandbox.workspaceAccess !== "none" && + path.resolve(this.sandbox.agentWorkspaceDir) !== path.resolve(this.sandbox.workspaceDir) + ) { + mounts.push({ + containerRoot: normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir), + writable: this.sandbox.workspaceAccess === "rw", + source: "agent", + }); + } + return mounts; + } + + private resolveTarget(params: { filePath: string; cwd?: string }): ResolvedRemotePath { + const workspaceRoot = path.resolve(this.sandbox.workspaceDir); + const agentRoot = path.resolve(this.sandbox.agentWorkspaceDir); + const workspaceContainerRoot = normalizeContainerPath(this.runtime.remoteWorkspaceDir); + const agentContainerRoot = normalizeContainerPath(this.runtime.remoteAgentWorkspaceDir); + const mounts = this.getMounts(); + const input = params.filePath.trim(); + const inputPosix = input.replace(/\\/g, "/"); + const maybeContainerMount = path.posix.isAbsolute(inputPosix) + ? this.resolveMountByContainerPath(mounts, normalizeContainerPath(inputPosix)) + : null; + if (maybeContainerMount) { + return this.toResolvedPath({ + mount: maybeContainerMount, + containerPath: normalizeContainerPath(inputPosix), + }); + } + + const hostCwd = params.cwd ? path.resolve(params.cwd) : workspaceRoot; + const hostCandidate = path.isAbsolute(input) + ? path.resolve(input) + : path.resolve(hostCwd, input); + if (isPathInside(workspaceRoot, hostCandidate)) { + const relative = toPosixRelative(workspaceRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[0], + containerPath: relative + ? path.posix.join(workspaceContainerRoot, relative) + : workspaceContainerRoot, + }); + } + if (mounts[1] && isPathInside(agentRoot, hostCandidate)) { + const relative = toPosixRelative(agentRoot, hostCandidate); + return this.toResolvedPath({ + mount: mounts[1], + containerPath: relative + ? path.posix.join(agentContainerRoot, relative) + : agentContainerRoot, + }); + } + + if (params.cwd) { + const cwdPosix = params.cwd.replace(/\\/g, "/"); + if (path.posix.isAbsolute(cwdPosix)) { + const cwdContainer = normalizeContainerPath(cwdPosix); + const cwdMount = this.resolveMountByContainerPath(mounts, cwdContainer); + if (cwdMount) { + return this.toResolvedPath({ + mount: cwdMount, + containerPath: normalizeContainerPath(path.posix.resolve(cwdContainer, inputPosix)), + }); + } + } + } + + throw new Error(`Sandbox path escapes allowed mounts; cannot access: ${params.filePath}`); + } + + private toResolvedPath(params: { mount: MountInfo; containerPath: string }): ResolvedRemotePath { + const relative = path.posix.relative(params.mount.containerRoot, params.containerPath); + if (relative.startsWith("..") || path.posix.isAbsolute(relative)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot access: ${params.containerPath}`, + ); + } + return { + relativePath: + params.mount.source === "workspace" + ? relative === "." + ? "" + : relative + : relative === "." + ? params.mount.containerRoot + : `${params.mount.containerRoot}/${relative}`, + containerPath: params.containerPath, + writable: params.mount.writable, + mountRootPath: params.mount.containerRoot, + source: params.mount.source, + }; + } + + private resolveMountByContainerPath( + mounts: MountInfo[], + containerPath: string, + ): MountInfo | null { + const ordered = [...mounts].toSorted((a, b) => b.containerRoot.length - a.containerRoot.length); + for (const mount of ordered) { + if (isPathInsideContainerRoot(mount.containerRoot, containerPath)) { + return mount; + } + } + return null; + } + + private ensureWritable(target: ResolvedRemotePath, action: string) { + if (this.sandbox.workspaceAccess !== "rw" || !target.writable) { + throw new Error(`Sandbox path is read-only; cannot ${action}: ${target.containerPath}`); + } + } + + private async remotePathExists(containerPath: string, signal?: AbortSignal): Promise { + const result = await this.runRemoteScript({ + script: 'if [ -e "$1" ] || [ -L "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + args: [containerPath], + signal, + }); + return result.stdout.toString("utf8").trim() === "1"; + } + + private async resolveCanonicalPath(params: { + containerPath: string; + action: string; + allowFinalSymlinkForUnlink?: boolean; + signal?: AbortSignal; + }): Promise { + const script = [ + "set -eu", + 'target="$1"', + 'allow_final="$2"', + 'suffix=""', + 'probe="$target"', + 'if [ "$allow_final" = "1" ] && [ -L "$target" ]; then probe=$(dirname -- "$target"); fi', + 'cursor="$probe"', + 'while [ ! -e "$cursor" ] && [ ! -L "$cursor" ]; do', + ' parent=$(dirname -- "$cursor")', + ' if [ "$parent" = "$cursor" ]; then break; fi', + ' base=$(basename -- "$cursor")', + ' suffix="/$base$suffix"', + ' cursor="$parent"', + "done", + 'canonical=$(readlink -f -- "$cursor")', + 'printf "%s%s\\n" "$canonical" "$suffix"', + ].join("\n"); + const result = await this.runRemoteScript({ + script, + args: [params.containerPath, params.allowFinalSymlinkForUnlink ? "1" : "0"], + signal: params.signal, + }); + const canonical = normalizeContainerPath(result.stdout.toString("utf8").trim()); + if (!this.resolveMountByContainerPath(this.getMounts(), canonical)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return canonical; + } + + private async assertNoHardlinkedFile(params: { + containerPath: string; + action: string; + signal?: AbortSignal; + }): Promise { + const result = await this.runRemoteScript({ + script: [ + 'if [ ! -e "$1" ] && [ ! -L "$1" ]; then exit 0; fi', + 'stats=$(stat -c "%F|%h" -- "$1")', + 'printf "%s\\n" "$stats"', + ].join("\n"), + args: [params.containerPath], + signal: params.signal, + allowFailure: true, + }); + const output = result.stdout.toString("utf8").trim(); + if (!output) { + return; + } + const [kind = "", linksRaw = "1"] = output.split("|"); + if (kind === "regular file" && Number(linksRaw) > 1) { + throw new Error( + `Hardlinked path is not allowed under sandbox mount root: ${params.containerPath}`, + ); + } + } + + private async resolvePinnedParent(params: { + containerPath: string; + action: string; + requireWritable?: boolean; + allowFinalSymlinkForUnlink?: boolean; + }): Promise<{ mountRootPath: string; relativeParentPath: string; basename: string }> { + const basename = path.posix.basename(params.containerPath); + if (!basename || basename === "." || basename === "/") { + throw new Error(`Invalid sandbox entry target: ${params.containerPath}`); + } + const canonicalParent = await this.resolveCanonicalPath({ + containerPath: normalizeContainerPath(path.posix.dirname(params.containerPath)), + action: params.action, + allowFinalSymlinkForUnlink: params.allowFinalSymlinkForUnlink, + }); + const mount = this.resolveMountByContainerPath(this.getMounts(), canonicalParent); + if (!mount) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + if (params.requireWritable && !mount.writable) { + throw new Error( + `Sandbox path is read-only; cannot ${params.action}: ${params.containerPath}`, + ); + } + const relativeParentPath = path.posix.relative(mount.containerRoot, canonicalParent); + if (relativeParentPath.startsWith("..") || path.posix.isAbsolute(relativeParentPath)) { + throw new Error( + `Sandbox path escapes allowed mounts; cannot ${params.action}: ${params.containerPath}`, + ); + } + return { + mountRootPath: mount.containerRoot, + relativeParentPath: relativeParentPath === "." ? "" : relativeParentPath, + basename, + }; + } + + private async runMutation(params: { + args: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + await this.runRemoteScript({ + script: [ + "set -eu", + "python3 /dev/fd/3 \"$@\" 3<<'PY'", + SANDBOX_PINNED_MUTATION_PYTHON, + "PY", + ].join("\n"), + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } + + private async runRemoteScript(params: { + script: string; + args?: string[]; + stdin?: Buffer | string; + signal?: AbortSignal; + allowFailure?: boolean; + }) { + return await this.runtime.runRemoteShellScript({ + script: params.script, + args: params.args, + stdin: params.stdin, + signal: params.signal, + allowFailure: params.allowFailure, + }); + } +} + +function normalizeContainerPath(value: string): string { + const normalized = path.posix.normalize(value.trim() || "/"); + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isPathInsideContainerRoot(root: string, candidate: string): boolean { + const normalizedRoot = normalizeContainerPath(root); + const normalizedCandidate = normalizeContainerPath(candidate); + return ( + normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`) + ); +} + +function isPathInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function toPosixRelative(root: string, candidate: string): string { + return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep); +} diff --git a/src/agents/sandbox/ssh-backend.ts b/src/agents/sandbox/ssh-backend.ts new file mode 100644 index 00000000000..f241103fc19 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.ts @@ -0,0 +1,303 @@ +import path from "node:path"; +import type { + CreateSandboxBackendParams, + SandboxBackendCommandParams, + SandboxBackendCommandResult, + SandboxBackendHandle, + SandboxBackendManager, +} from "./backend.js"; +import { resolveSandboxConfigForAgent } from "./config.js"; +import { + createRemoteShellSandboxFsBridge, + type RemoteShellSandboxHandle, +} from "./remote-fs-bridge.js"; +import { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + runSshSandboxCommand, + uploadDirectoryToSshTarget, + type SshSandboxSession, +} from "./ssh.js"; + +type PendingExec = { + sshSession: SshSandboxSession; +}; + +type ResolvedSshRuntimePaths = { + runtimeId: string; + runtimeRootDir: string; + remoteWorkspaceDir: string; + remoteAgentWorkspaceDir: string; +}; + +export const sshSandboxBackendManager: SandboxBackendManager = { + async describeRuntime({ entry, config, agentId }) { + const cfg = resolveSandboxConfigForAgent(config, agentId); + if (cfg.backend !== "ssh" || !cfg.ssh.target) { + return { + running: false, + actualConfigLabel: cfg.ssh.target, + configLabelMatch: false, + }; + } + const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey); + const session = await createSshSandboxSessionFromSettings({ + ...cfg.ssh, + target: cfg.ssh.target, + }); + try { + const result = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + runtimePaths.runtimeRootDir, + ]), + }); + return { + running: result.stdout.toString("utf8").trim() === "1", + actualConfigLabel: cfg.ssh.target, + configLabelMatch: entry.image === cfg.ssh.target, + }; + } finally { + await disposeSshSandboxSession(session); + } + }, + async removeRuntime({ entry, config, agentId }) { + const cfg = resolveSandboxConfigForAgent(config, agentId); + if (cfg.backend !== "ssh" || !cfg.ssh.target) { + return; + } + const runtimePaths = resolveSshRuntimePaths(cfg.ssh.workspaceRoot, entry.sessionKey); + const session = await createSshSandboxSessionFromSettings({ + ...cfg.ssh, + target: cfg.ssh.target, + }); + try { + await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'rm -rf -- "$1"', + "openclaw-sandbox-remove", + runtimePaths.runtimeRootDir, + ]), + allowFailure: true, + }); + } finally { + await disposeSshSandboxSession(session); + } + }, +}; + +export async function createSshSandboxBackend( + params: CreateSandboxBackendParams, +): Promise { + if ((params.cfg.docker.binds?.length ?? 0) > 0) { + throw new Error("SSH sandbox backend does not support sandbox.docker.binds."); + } + const target = params.cfg.ssh.target; + if (!target) { + throw new Error('Sandbox backend "ssh" requires agents.defaults.sandbox.ssh.target.'); + } + + const runtimePaths = resolveSshRuntimePaths(params.cfg.ssh.workspaceRoot, params.scopeKey); + const impl = new SshSandboxBackendImpl({ + createParams: params, + target, + runtimePaths, + }); + return impl.asHandle(); +} + +class SshSandboxBackendImpl { + private ensurePromise: Promise | null = null; + + constructor( + private readonly params: { + createParams: CreateSandboxBackendParams; + target: string; + runtimePaths: ResolvedSshRuntimePaths; + }, + ) {} + + asHandle(): SandboxBackendHandle & RemoteShellSandboxHandle { + return { + id: "ssh", + runtimeId: this.params.runtimePaths.runtimeId, + runtimeLabel: this.params.runtimePaths.runtimeId, + workdir: this.params.runtimePaths.remoteWorkspaceDir, + env: this.params.createParams.cfg.docker.env, + configLabel: this.params.target, + configLabelKind: "Target", + remoteWorkspaceDir: this.params.runtimePaths.remoteWorkspaceDir, + remoteAgentWorkspaceDir: this.params.runtimePaths.remoteAgentWorkspaceDir, + buildExecSpec: async ({ command, workdir, env, usePty }) => { + await this.ensureRuntime(); + const sshSession = await this.createSession(); + const remoteCommand = buildExecRemoteCommand({ + command, + workdir: workdir ?? this.params.runtimePaths.remoteWorkspaceDir, + env, + }); + return { + argv: buildSshSandboxArgv({ + session: sshSession, + remoteCommand, + tty: usePty, + }), + env: process.env, + stdinMode: "pipe-open", + finalizeToken: { sshSession } satisfies PendingExec, + }; + }, + finalizeExec: async ({ token }) => { + const sshSession = (token as PendingExec | undefined)?.sshSession; + if (sshSession) { + await disposeSshSandboxSession(sshSession); + } + }, + runShellCommand: async (command) => await this.runRemoteShellScript(command), + createFsBridge: ({ sandbox }) => + createRemoteShellSandboxFsBridge({ + sandbox, + runtime: this.asHandle(), + }), + runRemoteShellScript: async (command) => await this.runRemoteShellScript(command), + }; + } + + private async createSession(): Promise { + return await createSshSandboxSessionFromSettings({ + ...this.params.createParams.cfg.ssh, + target: this.params.target, + }); + } + + private async ensureRuntime(): Promise { + if (this.ensurePromise) { + return await this.ensurePromise; + } + this.ensurePromise = this.ensureRuntimeInner(); + try { + await this.ensurePromise; + } catch (error) { + this.ensurePromise = null; + throw error; + } + } + + private async ensureRuntimeInner(): Promise { + const session = await this.createSession(); + try { + const exists = await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'if [ -d "$1" ]; then printf "1\\n"; else printf "0\\n"; fi', + "openclaw-sandbox-check", + this.params.runtimePaths.runtimeRootDir, + ]), + }); + if (exists.stdout.toString("utf8").trim() === "1") { + return; + } + await this.replaceRemoteDirectoryFromLocal( + session, + this.params.createParams.workspaceDir, + this.params.runtimePaths.remoteWorkspaceDir, + ); + if ( + this.params.createParams.cfg.workspaceAccess !== "none" && + path.resolve(this.params.createParams.agentWorkspaceDir) !== + path.resolve(this.params.createParams.workspaceDir) + ) { + await this.replaceRemoteDirectoryFromLocal( + session, + this.params.createParams.agentWorkspaceDir, + this.params.runtimePaths.remoteAgentWorkspaceDir, + ); + } + } finally { + await disposeSshSandboxSession(session); + } + } + + private async replaceRemoteDirectoryFromLocal( + session: SshSandboxSession, + localDir: string, + remoteDir: string, + ): Promise { + await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && find "$1" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +', + "openclaw-sandbox-clear", + remoteDir, + ]), + }); + await uploadDirectoryToSshTarget({ + session, + localDir, + remoteDir, + }); + } + + async runRemoteShellScript( + params: SandboxBackendCommandParams, + ): Promise { + await this.ensureRuntime(); + const session = await this.createSession(); + try { + return await runSshSandboxCommand({ + session, + remoteCommand: buildRemoteCommand([ + "/bin/sh", + "-c", + params.script, + "openclaw-sandbox-fs", + ...(params.args ?? []), + ]), + stdin: params.stdin, + allowFailure: params.allowFailure, + signal: params.signal, + }); + } finally { + await disposeSshSandboxSession(session); + } + } +} + +function resolveSshRuntimePaths(workspaceRoot: string, scopeKey: string): ResolvedSshRuntimePaths { + const runtimeId = buildSshSandboxRuntimeId(scopeKey); + const runtimeRootDir = path.posix.join(workspaceRoot, runtimeId); + return { + runtimeId, + runtimeRootDir, + remoteWorkspaceDir: path.posix.join(runtimeRootDir, "workspace"), + remoteAgentWorkspaceDir: path.posix.join(runtimeRootDir, "agent"), + }; +} + +function buildSshSandboxRuntimeId(scopeKey: string): string { + const trimmed = scopeKey.trim() || "session"; + const safe = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32); + const hash = Array.from(trimmed).reduce( + (acc, char) => ((acc * 33) ^ char.charCodeAt(0)) >>> 0, + 5381, + ); + return `openclaw-ssh-${safe || "session"}-${hash.toString(16).slice(0, 8)}`; +} diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts new file mode 100644 index 00000000000..c2c07a3bf11 --- /dev/null +++ b/src/agents/sandbox/ssh.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildExecRemoteCommand, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, + type SshSandboxSession, +} from "./ssh.js"; + +const sessions: SshSandboxSession[] = []; + +afterEach(async () => { + await Promise.all( + sessions.splice(0).map(async (session) => { + await disposeSshSandboxSession(session); + }), + ); +}); + +describe("sandbox ssh helpers", () => { + it("materializes inline ssh auth data into a temp config", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: "PRIVATE KEY", + certificateData: "SSH CERT", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const config = await fs.readFile(session.configPath, "utf8"); + expect(config).toContain("Host openclaw-sandbox"); + expect(config).toContain("HostName example.com"); + expect(config).toContain("User peter"); + expect(config).toContain("Port 2222"); + expect(config).toContain("StrictHostKeyChecking yes"); + expect(config).toContain("UpdateHostKeys no"); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY"); + expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT"); + expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe( + "example.com ssh-ed25519 AAAATEST", + ); + }); + + it("wraps remote exec commands with env and workdir", () => { + const command = buildExecRemoteCommand({ + command: "pwd && printenv TOKEN", + workdir: "/sandbox/project", + env: { + TOKEN: "abc 123", + }, + }); + expect(command).toContain(`'env'`); + expect(command).toContain(`'TOKEN=abc 123'`); + expect(command).toContain(`'cd '"'"'/sandbox/project'"'"' && pwd && printenv TOKEN'`); + }); +}); diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts new file mode 100644 index 00000000000..1590b515e8f --- /dev/null +++ b/src/agents/sandbox/ssh.ts @@ -0,0 +1,334 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { parseSshTarget } from "../../infra/ssh-tunnel.js"; +import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; +import { resolveUserPath } from "../../utils.js"; +import type { SandboxBackendCommandResult } from "./backend.js"; + +export type SshSandboxSettings = { + command: string; + target: string; + strictHostKeyChecking: boolean; + updateHostKeys: boolean; + identityFile?: string; + certificateFile?: string; + knownHostsFile?: string; + identityData?: string; + certificateData?: string; + knownHostsData?: string; +}; + +export type SshSandboxSession = { + command: string; + configPath: string; + host: string; +}; + +export type RunSshSandboxCommandParams = { + session: SshSandboxSession; + remoteCommand: string; + stdin?: Buffer | string; + allowFailure?: boolean; + signal?: AbortSignal; + tty?: boolean; +}; + +export function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function buildRemoteCommand(argv: string[]): string { + return argv.map((entry) => shellEscape(entry)).join(" "); +} + +export function buildExecRemoteCommand(params: { + command: string; + workdir?: string; + env: Record; +}): string { + const body = params.workdir + ? `cd ${shellEscape(params.workdir)} && ${params.command}` + : params.command; + const argv = + Object.keys(params.env).length > 0 + ? [ + "env", + ...Object.entries(params.env).map(([key, value]) => `${key}=${value}`), + "/bin/sh", + "-c", + body, + ] + : ["/bin/sh", "-c", body]; + return buildRemoteCommand(argv); +} + +export function buildSshSandboxArgv(params: { + session: SshSandboxSession; + remoteCommand: string; + tty?: boolean; +}): string[] { + return [ + params.session.command, + "-F", + params.session.configPath, + ...(params.tty + ? ["-tt", "-o", "RequestTTY=force", "-o", "SetEnv=TERM=xterm-256color"] + : ["-T", "-o", "RequestTTY=no"]), + params.session.host, + params.remoteCommand, + ]; +} + +export async function createSshSandboxSessionFromConfigText(params: { + configText: string; + host?: string; + command?: string; +}): Promise { + const host = params.host?.trim() || parseSshConfigHost(params.configText); + if (!host) { + throw new Error("Failed to parse SSH config output."); + } + const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); + const configPath = path.join(configDir, "config"); + await fs.writeFile(configPath, params.configText, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(configPath, 0o600); + return { + command: params.command?.trim() || "ssh", + configPath, + host, + }; +} + +export async function createSshSandboxSessionFromSettings( + settings: SshSandboxSettings, +): Promise { + const parsed = parseSshTarget(settings.target); + if (!parsed) { + throw new Error(`Invalid sandbox SSH target: ${settings.target}`); + } + + const configDir = await fs.mkdtemp(path.join(resolveSshTmpRoot(), "openclaw-sandbox-ssh-")); + try { + const materializedIdentity = settings.identityData + ? await writeSecretMaterial(configDir, "identity", settings.identityData) + : undefined; + const materializedCertificate = settings.certificateData + ? await writeSecretMaterial(configDir, "certificate.pub", settings.certificateData) + : undefined; + const materializedKnownHosts = settings.knownHostsData + ? await writeSecretMaterial(configDir, "known_hosts", settings.knownHostsData) + : undefined; + const identityFile = materializedIdentity ?? resolveOptionalLocalPath(settings.identityFile); + const certificateFile = + materializedCertificate ?? resolveOptionalLocalPath(settings.certificateFile); + const knownHostsFile = + materializedKnownHosts ?? resolveOptionalLocalPath(settings.knownHostsFile); + const hostAlias = "openclaw-sandbox"; + const configPath = path.join(configDir, "config"); + const lines = [ + `Host ${hostAlias}`, + ` HostName ${parsed.host}`, + ` Port ${parsed.port}`, + " BatchMode yes", + " ConnectTimeout 5", + " ServerAliveInterval 15", + " ServerAliveCountMax 3", + ` StrictHostKeyChecking ${settings.strictHostKeyChecking ? "yes" : "no"}`, + ` UpdateHostKeys ${settings.updateHostKeys ? "yes" : "no"}`, + ]; + if (parsed.user) { + lines.push(` User ${parsed.user}`); + } + if (knownHostsFile) { + lines.push(` UserKnownHostsFile ${knownHostsFile}`); + } else if (!settings.strictHostKeyChecking) { + lines.push(" UserKnownHostsFile /dev/null"); + } + if (identityFile) { + lines.push(` IdentityFile ${identityFile}`); + } + if (certificateFile) { + lines.push(` CertificateFile ${certificateFile}`); + } + if (identityFile || certificateFile) { + lines.push(" IdentitiesOnly yes"); + } + await fs.writeFile(configPath, `${lines.join("\n")}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.chmod(configPath, 0o600); + return { + command: settings.command.trim() || "ssh", + configPath, + host: hostAlias, + }; + } catch (error) { + await fs.rm(configDir, { recursive: true, force: true }); + throw error; + } +} + +export async function disposeSshSandboxSession(session: SshSandboxSession): Promise { + await fs.rm(path.dirname(session.configPath), { recursive: true, force: true }); +} + +export async function runSshSandboxCommand( + params: RunSshSandboxCommandParams, +): Promise { + const argv = buildSshSandboxArgv({ + session: params.session, + remoteCommand: params.remoteCommand, + tty: params.tty, + }); + return await new Promise((resolve, reject) => { + const child = spawn(argv[0], argv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (code) => { + const stdout = Buffer.concat(stdoutChunks); + const stderr = Buffer.concat(stderrChunks); + const exitCode = code ?? 0; + if (exitCode !== 0 && !params.allowFailure) { + reject( + Object.assign( + new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), + { + code: exitCode, + stdout, + stderr, + }, + ), + ); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + + if (params.stdin !== undefined) { + child.stdin.end(params.stdin); + return; + } + child.stdin.end(); + }); +} + +export async function uploadDirectoryToSshTarget(params: { + session: SshSandboxSession; + localDir: string; + remoteDir: string; + signal?: AbortSignal; +}): Promise { + const remoteCommand = buildRemoteCommand([ + "/bin/sh", + "-c", + 'mkdir -p -- "$1" && tar -xf - -C "$1"', + "openclaw-sandbox-upload", + params.remoteDir, + ]); + const sshArgv = buildSshSandboxArgv({ + session: params.session, + remoteCommand, + }); + await new Promise((resolve, reject) => { + const tar = spawn("tar", ["-C", params.localDir, "-cf", "-", "."], { + stdio: ["ignore", "pipe", "pipe"], + signal: params.signal, + }); + const ssh = spawn(sshArgv[0], sshArgv.slice(1), { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + signal: params.signal, + }); + const tarStderr: Buffer[] = []; + const sshStdout: Buffer[] = []; + const sshStderr: Buffer[] = []; + let tarClosed = false; + let sshClosed = false; + let tarCode = 0; + let sshCode = 0; + + tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk))); + ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk))); + ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk))); + + const fail = (error: unknown) => { + tar.kill("SIGKILL"); + ssh.kill("SIGKILL"); + reject(error); + }; + + tar.on("error", fail); + ssh.on("error", fail); + tar.stdout.pipe(ssh.stdin); + + tar.on("close", (code) => { + tarClosed = true; + tarCode = code ?? 0; + maybeResolve(); + }); + ssh.on("close", (code) => { + sshClosed = true; + sshCode = code ?? 0; + maybeResolve(); + }); + + function maybeResolve() { + if (!tarClosed || !sshClosed) { + return; + } + if (tarCode !== 0) { + reject( + new Error( + Buffer.concat(tarStderr).toString("utf8").trim() || `tar exited with code ${tarCode}`, + ), + ); + return; + } + if (sshCode !== 0) { + reject( + new Error( + Buffer.concat(sshStderr).toString("utf8").trim() || `ssh exited with code ${sshCode}`, + ), + ); + return; + } + resolve(); + } + }); +} + +function parseSshConfigHost(configText: string): string | null { + const hostMatch = configText.match(/^\s*Host\s+(\S+)/m); + return hostMatch?.[1]?.trim() || null; +} + +function resolveSshTmpRoot(): string { + return path.resolve(resolvePreferredOpenClawTmpDir() ?? os.tmpdir()); +} + +function resolveOptionalLocalPath(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? resolveUserPath(trimmed) : undefined; +} + +async function writeSecretMaterial( + dir: string, + filename: string, + contents: string, +): Promise { + const pathname = path.join(dir, filename); + await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(pathname, 0o600); + return pathname; +} diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index 8244583ea0c..482ce6a922e 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -51,6 +51,20 @@ export type SandboxPruneConfig = { maxAgeDays: number; }; +export type SandboxSshConfig = { + target?: string; + command: string; + workspaceRoot: string; + strictHostKeyChecking: boolean; + updateHostKeys: boolean; + identityFile?: string; + certificateFile?: string; + knownHostsFile?: string; + identityData?: string; + certificateData?: string; + knownHostsData?: string; +}; + export type SandboxScope = "session" | "agent" | "shared"; export type SandboxConfig = { @@ -60,6 +74,7 @@ export type SandboxConfig = { workspaceAccess: SandboxWorkspaceAccess; workspaceRoot: string; docker: SandboxDockerConfig; + ssh: SandboxSshConfig; browser: SandboxBrowserConfig; tools: SandboxToolPolicy; prune: SandboxPruneConfig; diff --git a/src/config/types.agents-shared.ts b/src/config/types.agents-shared.ts index 1e398cc1c70..3351d9903c9 100644 --- a/src/config/types.agents-shared.ts +++ b/src/config/types.agents-shared.ts @@ -2,6 +2,7 @@ import type { SandboxBrowserSettings, SandboxDockerSettings, SandboxPruneSettings, + SandboxSshSettings, } from "./types.sandbox.js"; export type AgentModelConfig = @@ -32,6 +33,8 @@ export type AgentSandboxConfig = { workspaceRoot?: string; /** Docker-specific sandbox settings. */ docker?: SandboxDockerSettings; + /** SSH-specific sandbox settings. */ + ssh?: SandboxSshSettings; /** Optional sandboxed browser settings. */ browser?: SandboxBrowserSettings; /** Auto-prune sandbox settings. */ diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 047f10cde53..04128e2ffaa 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -1,3 +1,5 @@ +import type { SecretInput } from "./types.secrets.js"; + export type SandboxDockerSettings = { /** Docker image to use for sandbox containers. */ image?: string; @@ -94,3 +96,28 @@ export type SandboxPruneSettings = { /** Prune if older than N days (0 disables). */ maxAgeDays?: number; }; + +export type SandboxSshSettings = { + /** SSH target in user@host[:port] form. */ + target?: string; + /** SSH client command. Default: "ssh". */ + command?: string; + /** Absolute remote root used for per-scope workspaces. */ + workspaceRoot?: string; + /** Enforce host-key verification. Default: true. */ + strictHostKeyChecking?: boolean; + /** Allow OpenSSH host-key updates. Default: true. */ + updateHostKeys?: boolean; + /** Existing private key path on the host. */ + identityFile?: string; + /** Existing SSH certificate path on the host. */ + certificateFile?: string; + /** Existing known_hosts file path on the host. */ + knownHostsFile?: string; + /** Inline or SecretRef-backed private key contents. */ + identityData?: SecretInput; + /** Inline or SecretRef-backed SSH certificate contents. */ + certificateData?: SecretInput; + /** Inline or SecretRef-backed known_hosts contents. */ + knownHostsData?: SecretInput; +}; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 9ddbedf929e..10cef396275 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -501,6 +501,23 @@ const ToolLoopDetectionSchema = z }) .optional(); +export const SandboxSshSchema = z + .object({ + target: z.string().min(1).optional(), + command: z.string().min(1).optional(), + workspaceRoot: z.string().min(1).optional(), + strictHostKeyChecking: z.boolean().optional(), + updateHostKeys: z.boolean().optional(), + identityFile: z.string().min(1).optional(), + certificateFile: z.string().min(1).optional(), + knownHostsFile: z.string().min(1).optional(), + identityData: SecretInputSchema.optional().register(sensitive), + certificateData: SecretInputSchema.optional().register(sensitive), + knownHostsData: SecretInputSchema.optional().register(sensitive), + }) + .strict() + .optional(); + export const AgentSandboxSchema = z .object({ mode: z.union([z.literal("off"), z.literal("non-main"), z.literal("all")]).optional(), @@ -511,6 +528,7 @@ export const AgentSandboxSchema = z perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: SandboxDockerSchema, + ssh: SandboxSshSchema, browser: SandboxBrowserSchema, prune: SandboxPruneSchema, }) diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index f3a6d1ca16b..025efaff67a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -31,6 +31,8 @@ export type { } from "../plugins/types.js"; export type { CreateSandboxBackendParams, + RemoteShellSandboxHandle, + RunSshSandboxCommandParams, SandboxBackendCommandParams, SandboxBackendCommandResult, SandboxBackendExecSpec, @@ -44,6 +46,9 @@ export type { SandboxBackendRuntimeInfo, SandboxContext, SandboxResolvedPath, + SandboxSshConfig, + SshSandboxSession, + SshSandboxSettings, } from "../agents/sandbox.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; @@ -57,9 +62,19 @@ export type { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { + buildExecRemoteCommand, + buildRemoteCommand, + buildSshSandboxArgv, + createRemoteShellSandboxFsBridge, + createSshSandboxSessionFromConfigText, + createSshSandboxSessionFromSettings, + disposeSshSandboxSession, getSandboxBackendFactory, getSandboxBackendManager, registerSandboxBackend, + runSshSandboxCommand, + shellEscape, + uploadDirectoryToSshTarget, requireSandboxBackendFactory, } from "../agents/sandbox.js"; export { buildOauthProviderAuthResult } from "./provider-auth-result.js"; diff --git a/src/secrets/runtime-config-collectors-core.ts b/src/secrets/runtime-config-collectors-core.ts index 99668371ad1..ef571b3f54f 100644 --- a/src/secrets/runtime-config-collectors-core.ts +++ b/src/secrets/runtime-config-collectors-core.ts @@ -313,6 +313,90 @@ function collectCronAssignments(params: { }); } +function collectSandboxSshAssignments(params: { + config: OpenClawConfig; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const agents = isRecord(params.config.agents) ? params.config.agents : undefined; + if (!agents) { + return; + } + const defaultsAgent = isRecord(agents.defaults) ? agents.defaults : undefined; + const defaultsSandbox = isRecord(defaultsAgent?.sandbox) ? defaultsAgent.sandbox : undefined; + const defaultsSsh = isRecord(defaultsSandbox?.ssh) + ? (defaultsSandbox.ssh as Record) + : undefined; + const defaultsBackend = + typeof defaultsSandbox?.backend === "string" ? defaultsSandbox.backend : undefined; + const defaultsMode = typeof defaultsSandbox?.mode === "string" ? defaultsSandbox.mode : undefined; + + const inheritedDefaultsUsage = { + identityData: false, + certificateData: false, + knownHostsData: false, + }; + + const list = Array.isArray(agents.list) ? agents.list : []; + list.forEach((rawAgent, index) => { + const agentRecord = isRecord(rawAgent) ? (rawAgent as Record) : null; + if (!agentRecord || agentRecord.enabled === false) { + return; + } + const sandbox = isRecord(agentRecord.sandbox) ? agentRecord.sandbox : undefined; + const ssh = isRecord(sandbox?.ssh) ? sandbox.ssh : undefined; + const effectiveBackend = + (typeof sandbox?.backend === "string" ? sandbox.backend : undefined) ?? + defaultsBackend ?? + "docker"; + const effectiveMode = + (typeof sandbox?.mode === "string" ? sandbox.mode : undefined) ?? defaultsMode ?? "off"; + const active = effectiveBackend.trim().toLowerCase() === "ssh" && effectiveMode !== "off"; + for (const key of ["identityData", "certificateData", "knownHostsData"] as const) { + if (ssh && Object.prototype.hasOwnProperty.call(ssh, key)) { + collectSecretInputAssignment({ + value: ssh[key], + path: `agents.list.${index}.sandbox.ssh.${key}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active, + inactiveReason: "sandbox SSH backend is not active for this agent.", + apply: (value) => { + ssh[key] = value; + }, + }); + } else if (active) { + inheritedDefaultsUsage[key] = true; + } + } + }); + + if (!defaultsSsh) { + return; + } + + const defaultsActive = + (defaultsBackend?.trim().toLowerCase() === "ssh" && defaultsMode !== "off") || + inheritedDefaultsUsage.identityData || + inheritedDefaultsUsage.certificateData || + inheritedDefaultsUsage.knownHostsData; + for (const key of ["identityData", "certificateData", "knownHostsData"] as const) { + collectSecretInputAssignment({ + value: defaultsSsh[key], + path: `agents.defaults.sandbox.ssh.${key}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: defaultsActive || inheritedDefaultsUsage[key], + inactiveReason: "sandbox SSH backend is not active.", + apply: (value) => { + defaultsSsh[key] = value; + }, + }); + } +} + export function collectCoreConfigAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; @@ -339,6 +423,7 @@ export function collectCoreConfigAssignments(params: { collectAgentMemorySearchAssignments(params); collectTalkAssignments(params); collectGatewayAssignments(params); + collectSandboxSshAssignments(params); collectMessagesTtsAssignments(params); collectCronAssignments(params); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 47628f1bfe2..837a174efaa 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -221,6 +221,46 @@ describe("secrets runtime snapshot", () => { ).toEqual({ source: "env", provider: "default", id: "OPENAI_API_KEY" }); }); + it("resolves sandbox ssh secret refs for active ssh backends", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + ssh: { + target: "peter@example.com:22", + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" }, + certificateData: { + source: "env", + provider: "default", + id: "SSH_CERTIFICATE_DATA", + }, + knownHostsData: { + source: "env", + provider: "default", + id: "SSH_KNOWN_HOSTS_DATA", + }, + }, + }, + }, + }, + }), + env: { + SSH_IDENTITY_DATA: "PRIVATE KEY", + SSH_CERTIFICATE_DATA: "SSH CERT", + SSH_KNOWN_HOSTS_DATA: "example.com ssh-ed25519 AAAATEST", + }, + }); + + expect(snapshot.config.agents?.defaults?.sandbox?.ssh).toMatchObject({ + identityData: "PRIVATE KEY", + certificateData: "SSH CERT", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + }); + it("normalizes inline SecretRef object on token to tokenRef", async () => { const config: OpenClawConfig = { models: {}, secrets: {} }; const snapshot = await prepareSecretsRuntimeSnapshot({ From 0a2f95916be6354d6e898ec3b8eb45015e16f16a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:38:22 -0700 Subject: [PATCH 006/215] test: expand ssh sandbox coverage and docs --- docs/cli/sandbox.md | 6 + docs/gateway/configuration-reference.md | 7 + docs/gateway/sandboxing.md | 12 + docs/gateway/secrets.md | 29 ++ src/agents/sandbox/ssh-backend.test.ts | 338 ++++++++++++++++++++++++ src/secrets/runtime.test.ts | 33 +++ 6 files changed, 425 insertions(+) create mode 100644 src/agents/sandbox/ssh-backend.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index f320be3b771..5764851dc70 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -19,6 +19,12 @@ Today that usually means: - SSH sandbox runtimes when `agents.defaults.sandbox.backend = "ssh"` - OpenShell sandbox runtimes when `agents.defaults.sandbox.backend = "openshell"` +For `ssh` and OpenShell `remote`, recreate matters more than with Docker: + +- the remote workspace is canonical after the initial seed +- `openclaw sandbox recreate` deletes that canonical remote workspace for the selected scope +- next use seeds it again from the current local workspace + ## Commands ### `openclaw sandbox explain` diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ecefd8bbc4e..0653fd3834f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1232,6 +1232,13 @@ When `backend: "openshell"` is selected, runtime-specific settings move to - `identityData` / `certificateData` / `knownHostsData`: inline contents or SecretRefs that OpenClaw materializes into temp files at runtime - `strictHostKeyChecking` / `updateHostKeys`: OpenSSH host-key policy knobs +**SSH auth precedence:** + +- `identityData` wins over `identityFile` +- `certificateData` wins over `certificateFile` +- `knownHostsData` wins over `knownHostsFile` +- SecretRef-backed `*Data` values are resolved from the active secrets runtime snapshot before the sandbox session starts + **SSH backend behavior:** - seeds the remote workspace once after create or recreate diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index b37757334c0..c6cf839e42d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -105,6 +105,12 @@ How it works: - After that, `exec`, `read`, `write`, `edit`, `apply_patch`, prompt media reads, and inbound media staging run directly against the remote workspace over SSH. - OpenClaw does not sync remote changes back to the local workspace automatically. +Authentication material: + +- `identityFile`, `certificateFile`, `knownHostsFile`: use existing local files and pass them through OpenSSH config. +- `identityData`, `certificateData`, `knownHostsData`: use inline strings or SecretRefs. OpenClaw resolves them through the normal secrets runtime snapshot, writes them to temp files with `0600`, and deletes them when the SSH session ends. +- If both `*File` and `*Data` are set for the same item, `*Data` wins for that SSH session. + This is a **remote-canonical** model. The remote SSH workspace becomes the real sandbox state after the initial seed. Important consequences: @@ -150,6 +156,12 @@ OpenShell modes: OpenShell reuses the same core SSH transport and remote filesystem bridge as the generic SSH backend. The plugin adds OpenShell-specific lifecycle (`sandbox create/get/delete`, `sandbox ssh-config`) and the optional `mirror` mode. +Remote transport details: + +- OpenClaw asks OpenShell for sandbox-specific SSH config via `openshell sandbox ssh-config `. +- Core writes that SSH config to a temp file, opens the SSH session, and reuses the same remote filesystem bridge used by `backend: "ssh"`. +- In `mirror` mode only the lifecycle differs: sync local to remote before exec, then sync back after exec. + Current OpenShell limitations: - sandbox browser is not supported yet diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index eb044eaf03c..05554b1f6d3 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -288,6 +288,35 @@ Optional per-id errors: } ``` +## Sandbox SSH auth material + +The core `ssh` sandbox backend also supports SecretRefs for SSH auth material: + +```json5 +{ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + ssh: { + target: "user@gateway-host:22", + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY" }, + certificateData: { source: "env", provider: "default", id: "SSH_CERTIFICATE" }, + knownHostsData: { source: "env", provider: "default", id: "SSH_KNOWN_HOSTS" }, + }, + }, + }, + }, +} +``` + +Runtime behavior: + +- OpenClaw resolves these refs during sandbox activation, not lazily during each SSH call. +- Resolved values are written to temp files with restrictive permissions and used in generated SSH config. +- If the effective sandbox backend is not `ssh`, these refs stay inactive and do not block startup. + ## Supported credential surface Canonical supported and unsupported credentials are listed in: diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts new file mode 100644 index 00000000000..c8ec3b5f750 --- /dev/null +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -0,0 +1,338 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const sshMocks = vi.hoisted(() => ({ + createSshSandboxSessionFromSettings: vi.fn(), + disposeSshSandboxSession: vi.fn(), + runSshSandboxCommand: vi.fn(), + uploadDirectoryToSshTarget: vi.fn(), + buildSshSandboxArgv: vi.fn(), +})); + +vi.mock("./ssh.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSshSandboxSessionFromSettings: sshMocks.createSshSandboxSessionFromSettings, + disposeSshSandboxSession: sshMocks.disposeSshSandboxSession, + runSshSandboxCommand: sshMocks.runSshSandboxCommand, + uploadDirectoryToSshTarget: sshMocks.uploadDirectoryToSshTarget, + buildSshSandboxArgv: sshMocks.buildSshSandboxArgv, + }; +}); + +import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; + +function createConfig(): OpenClawConfig { + return { + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + ssh: { + target: "peter@example.com:2222", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + }, + }, + }, + }; +} + +function createSession() { + return { + command: "ssh", + configPath: path.join(os.tmpdir(), "openclaw-test-ssh-config"), + host: "openclaw-sandbox", + }; +} + +describe("ssh sandbox backend", () => { + beforeEach(() => { + vi.clearAllMocks(); + sshMocks.createSshSandboxSessionFromSettings.mockResolvedValue(createSession()); + sshMocks.disposeSshSandboxSession.mockResolvedValue(undefined); + sshMocks.runSshSandboxCommand.mockResolvedValue({ + stdout: Buffer.from("1\n"), + stderr: Buffer.alloc(0), + code: 0, + }); + sshMocks.uploadDirectoryToSshTarget.mockResolvedValue(undefined); + sshMocks.buildSshSandboxArgv.mockImplementation(({ session, remoteCommand, tty }) => [ + session.command, + "-F", + session.configPath, + tty ? "-tt" : "-T", + session.host, + remoteCommand, + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("describes runtimes via the configured ssh target", async () => { + const result = await sshSandboxBackendManager.describeRuntime({ + entry: { + containerName: "openclaw-ssh-worker-abcd1234", + backendId: "ssh", + runtimeLabel: "openclaw-ssh-worker-abcd1234", + sessionKey: "agent:worker", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "peter@example.com:2222", + configLabelKind: "Target", + }, + config: createConfig(), + }); + + expect(result).toEqual({ + running: true, + actualConfigLabel: "peter@example.com:2222", + configLabelMatch: true, + }); + expect(sshMocks.createSshSandboxSessionFromSettings).toHaveBeenCalledWith( + expect.objectContaining({ + target: "peter@example.com:2222", + workspaceRoot: "/remote/openclaw", + }), + ); + expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith( + expect.objectContaining({ + remoteCommand: expect.stringContaining("/remote/openclaw/openclaw-ssh-agent-worker"), + }), + ); + }); + + it("removes runtimes by deleting the remote scope root", async () => { + await sshSandboxBackendManager.removeRuntime({ + entry: { + containerName: "openclaw-ssh-worker-abcd1234", + backendId: "ssh", + runtimeLabel: "openclaw-ssh-worker-abcd1234", + sessionKey: "agent:worker", + createdAtMs: 1, + lastUsedAtMs: 1, + image: "peter@example.com:2222", + configLabelKind: "Target", + }, + config: createConfig(), + }); + + expect(sshMocks.runSshSandboxCommand).toHaveBeenCalledWith( + expect.objectContaining({ + allowFailure: true, + remoteCommand: expect.stringContaining('rm -rf -- "$1"'), + }), + ); + }); + + it("creates a remote-canonical backend that seeds once and reuses ssh exec", async () => { + sshMocks.runSshSandboxCommand + .mockResolvedValueOnce({ + stdout: Buffer.from("0\n"), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }) + .mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + code: 0, + }); + + const backend = await createSshSandboxBackend({ + sessionKey: "agent:worker:task", + scopeKey: "agent:worker", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/agent", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "openclaw-sandbox:bookworm-slim", + containerPrefix: "openclaw-sbx-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: { LANG: "C.UTF-8" }, + }, + ssh: { + target: "peter@example.com:2222", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "openclaw-browser", + containerPrefix: "openclaw-browser-", + network: "bridge", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1000, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }); + + const execSpec = await backend.buildExecSpec({ + command: "pwd", + env: { TEST_TOKEN: "1" }, + usePty: false, + }); + + expect(execSpec.argv).toEqual( + expect.arrayContaining(["ssh", "-F", createSession().configPath, "-T", createSession().host]), + ); + expect(execSpec.argv.at(-1)).toContain("/remote/openclaw/openclaw-ssh-agent-worker"); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenCalledTimes(2); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + localDir: "/tmp/workspace", + remoteDir: expect.stringContaining("/workspace"), + }), + ); + expect(sshMocks.uploadDirectoryToSshTarget).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + localDir: "/tmp/agent", + remoteDir: expect.stringContaining("/agent"), + }), + ); + + await backend.finalizeExec?.({ + status: "completed", + exitCode: 0, + timedOut: false, + token: execSpec.finalizeToken, + }); + expect(sshMocks.disposeSshSandboxSession).toHaveBeenCalled(); + }); + + it("rejects docker binds and missing ssh target", async () => { + await expect( + createSshSandboxBackend({ + sessionKey: "s", + scopeKey: "s", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "img", + containerPrefix: "prefix-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + binds: ["/tmp:/tmp:rw"], + }, + ssh: { + target: "peter@example.com:22", + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "img", + containerPrefix: "prefix-", + network: "bridge", + cdpPort: 1, + vncPort: 2, + noVncPort: 3, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }), + ).rejects.toThrow("does not support sandbox.docker.binds"); + + await expect( + createSshSandboxBackend({ + sessionKey: "s", + scopeKey: "s", + workspaceDir: "/tmp/workspace", + agentWorkspaceDir: "/tmp/workspace", + cfg: { + mode: "all", + backend: "ssh", + scope: "session", + workspaceAccess: "rw", + workspaceRoot: "~/.openclaw/sandboxes", + docker: { + image: "img", + containerPrefix: "prefix-", + workdir: "/workspace", + readOnlyRoot: true, + tmpfs: ["/tmp"], + network: "none", + capDrop: ["ALL"], + env: {}, + }, + ssh: { + command: "ssh", + workspaceRoot: "/remote/openclaw", + strictHostKeyChecking: true, + updateHostKeys: true, + }, + browser: { + enabled: false, + image: "img", + containerPrefix: "prefix-", + network: "bridge", + cdpPort: 1, + vncPort: 2, + noVncPort: 3, + headless: true, + enableNoVnc: false, + allowHostControl: false, + autoStart: false, + autoStartTimeoutMs: 1, + }, + tools: { allow: [], deny: [] }, + prune: { idleHours: 24, maxAgeDays: 7 }, + }, + }), + ).rejects.toThrow("requires agents.defaults.sandbox.ssh.target"); + }); +}); diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 837a174efaa..8e7e549ae51 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -261,6 +261,39 @@ describe("secrets runtime snapshot", () => { }); }); + it("treats sandbox ssh secret refs as inactive when ssh backend is not selected", async () => { + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + agents: { + defaults: { + sandbox: { + mode: "all", + backend: "docker", + ssh: { + identityData: { source: "env", provider: "default", id: "SSH_IDENTITY_DATA" }, + }, + }, + }, + }, + }), + env: {}, + }); + + expect(snapshot.config.agents?.defaults?.sandbox?.ssh?.identityData).toEqual({ + source: "env", + provider: "default", + id: "SSH_IDENTITY_DATA", + }); + expect(snapshot.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE", + path: "agents.defaults.sandbox.ssh.identityData", + }), + ]), + ); + }); + it("normalizes inline SecretRef object on token to tokenRef", async () => { const config: OpenClawConfig = { models: {}, secrets: {} }; const snapshot = await prepareSecretsRuntimeSnapshot({ From 1beea52d8dfd8c9248a24fa5bc982d78e4d7396a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 21:37:19 -0700 Subject: [PATCH 007/215] refactor: rename setup wizard surfaces --- src/canvas-host/a2ui/a2ui.bundle.js | 15272 ++++++++++++++++++++++++++ 1 file changed, 15272 insertions(+) create mode 100644 src/canvas-host/a2ui/a2ui.bundle.js diff --git a/src/canvas-host/a2ui/a2ui.bundle.js b/src/canvas-host/a2ui/a2ui.bundle.js new file mode 100644 index 00000000000..d12450da71f --- /dev/null +++ b/src/canvas-host/a2ui/a2ui.bundle.js @@ -0,0 +1,15272 @@ +var __defProp$1 = Object.defineProperty; +var __exportAll = (all, no_symbols) => { + let target = {}; + for (var name in all) + __defProp$1(target, name, { + get: all[name], + enumerable: true, + }); + if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" }); + return target; +}; +/** + * @license + * Copyright 2019 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$6 = globalThis, + e$13 = + t$6.ShadowRoot && + (void 0 === t$6.ShadyCSS || t$6.ShadyCSS.nativeShadow) && + "adoptedStyleSheets" in Document.prototype && + "replace" in CSSStyleSheet.prototype, + s$8 = Symbol(), + o$14 = /* @__PURE__ */ new WeakMap(); +var n$12 = class { + constructor(t, e, o) { + if (((this._$cssResult$ = !0), o !== s$8)) + throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + ((this.cssText = t), (this.t = e)); + } + get styleSheet() { + let t = this.o; + const s = this.t; + if (e$13 && void 0 === t) { + const e = void 0 !== s && 1 === s.length; + (e && (t = o$14.get(s)), + void 0 === t && + ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), e && o$14.set(s, t))); + } + return t; + } + toString() { + return this.cssText; + } +}; +const r$11 = (t) => new n$12("string" == typeof t ? t : t + "", void 0, s$8), + i$9 = (t, ...e) => { + return new n$12( + 1 === t.length + ? t[0] + : e.reduce( + (e, s, o) => + e + + ((t) => { + if (!0 === t._$cssResult$) return t.cssText; + if ("number" == typeof t) return t; + throw Error( + "Value passed to 'css' function must be a 'css' function result: " + + t + + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.", + ); + })(s) + + t[o + 1], + t[0], + ), + t, + s$8, + ); + }, + S$1 = (s, o) => { + if (e$13) s.adoptedStyleSheets = o.map((t) => (t instanceof CSSStyleSheet ? t : t.styleSheet)); + else + for (const e of o) { + const o = document.createElement("style"), + n = t$6.litNonce; + (void 0 !== n && o.setAttribute("nonce", n), (o.textContent = e.cssText), s.appendChild(o)); + } + }, + c$6 = e$13 + ? (t) => t + : (t) => + t instanceof CSSStyleSheet + ? ((t) => { + let e = ""; + for (const s of t.cssRules) e += s.cssText; + return r$11(e); + })(t) + : t; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const { + is: i$8, + defineProperty: e$12, + getOwnPropertyDescriptor: h$6, + getOwnPropertyNames: r$10, + getOwnPropertySymbols: o$13, + getPrototypeOf: n$11, + } = Object, + a$1 = globalThis, + c$5 = a$1.trustedTypes, + l$4 = c$5 ? c$5.emptyScript : "", + p$2 = a$1.reactiveElementPolyfillSupport, + d$2 = (t, s) => t, + u$3 = { + toAttribute(t, s) { + switch (s) { + case Boolean: + t = t ? l$4 : null; + break; + case Object: + case Array: + t = null == t ? t : JSON.stringify(t); + } + return t; + }, + fromAttribute(t, s) { + let i = t; + switch (s) { + case Boolean: + i = null !== t; + break; + case Number: + i = null === t ? null : Number(t); + break; + case Object: + case Array: + try { + i = JSON.parse(t); + } catch (t) { + i = null; + } + } + return i; + }, + }, + f$3 = (t, s) => !i$8(t, s), + b$1 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + useDefault: !1, + hasChanged: f$3, + }; +((Symbol.metadata ??= Symbol("metadata")), + (a$1.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap())); +var y$1 = class extends HTMLElement { + static addInitializer(t) { + (this._$Ei(), (this.l ??= []).push(t)); + } + static get observedAttributes() { + return (this.finalize(), this._$Eh && [...this._$Eh.keys()]); + } + static createProperty(t, s = b$1) { + if ( + (s.state && (s.attribute = !1), + this._$Ei(), + this.prototype.hasOwnProperty(t) && ((s = Object.create(s)).wrapped = !0), + this.elementProperties.set(t, s), + !s.noAccessor) + ) { + const i = Symbol(), + h = this.getPropertyDescriptor(t, i, s); + void 0 !== h && e$12(this.prototype, t, h); + } + } + static getPropertyDescriptor(t, s, i) { + const { get: e, set: r } = h$6(this.prototype, t) ?? { + get() { + return this[s]; + }, + set(t) { + this[s] = t; + }, + }; + return { + get: e, + set(s) { + const h = e?.call(this); + (r?.call(this, s), this.requestUpdate(t, h, i)); + }, + configurable: !0, + enumerable: !0, + }; + } + static getPropertyOptions(t) { + return this.elementProperties.get(t) ?? b$1; + } + static _$Ei() { + if (this.hasOwnProperty(d$2("elementProperties"))) return; + const t = n$11(this); + (t.finalize(), + void 0 !== t.l && (this.l = [...t.l]), + (this.elementProperties = new Map(t.elementProperties))); + } + static finalize() { + if (this.hasOwnProperty(d$2("finalized"))) return; + if (((this.finalized = !0), this._$Ei(), this.hasOwnProperty(d$2("properties")))) { + const t = this.properties, + s = [...r$10(t), ...o$13(t)]; + for (const i of s) this.createProperty(i, t[i]); + } + const t = this[Symbol.metadata]; + if (null !== t) { + const s = litPropertyMetadata.get(t); + if (void 0 !== s) for (const [t, i] of s) this.elementProperties.set(t, i); + } + this._$Eh = /* @__PURE__ */ new Map(); + for (const [t, s] of this.elementProperties) { + const i = this._$Eu(t, s); + void 0 !== i && this._$Eh.set(i, t); + } + this.elementStyles = this.finalizeStyles(this.styles); + } + static finalizeStyles(s) { + const i = []; + if (Array.isArray(s)) { + const e = new Set(s.flat(Infinity).reverse()); + for (const s of e) i.unshift(c$6(s)); + } else void 0 !== s && i.push(c$6(s)); + return i; + } + static _$Eu(t, s) { + const i = s.attribute; + return !1 === i + ? void 0 + : "string" == typeof i + ? i + : "string" == typeof t + ? t.toLowerCase() + : void 0; + } + constructor() { + (super(), + (this._$Ep = void 0), + (this.isUpdatePending = !1), + (this.hasUpdated = !1), + (this._$Em = null), + this._$Ev()); + } + _$Ev() { + ((this._$ES = new Promise((t) => (this.enableUpdating = t))), + (this._$AL = /* @__PURE__ */ new Map()), + this._$E_(), + this.requestUpdate(), + this.constructor.l?.forEach((t) => t(this))); + } + addController(t) { + ((this._$EO ??= /* @__PURE__ */ new Set()).add(t), + void 0 !== this.renderRoot && this.isConnected && t.hostConnected?.()); + } + removeController(t) { + this._$EO?.delete(t); + } + _$E_() { + const t = /* @__PURE__ */ new Map(), + s = this.constructor.elementProperties; + for (const i of s.keys()) this.hasOwnProperty(i) && (t.set(i, this[i]), delete this[i]); + t.size > 0 && (this._$Ep = t); + } + createRenderRoot() { + const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); + return (S$1(t, this.constructor.elementStyles), t); + } + connectedCallback() { + ((this.renderRoot ??= this.createRenderRoot()), + this.enableUpdating(!0), + this._$EO?.forEach((t) => t.hostConnected?.())); + } + enableUpdating(t) {} + disconnectedCallback() { + this._$EO?.forEach((t) => t.hostDisconnected?.()); + } + attributeChangedCallback(t, s, i) { + this._$AK(t, i); + } + _$ET(t, s) { + const i = this.constructor.elementProperties.get(t), + e = this.constructor._$Eu(t, i); + if (void 0 !== e && !0 === i.reflect) { + const h = (void 0 !== i.converter?.toAttribute ? i.converter : u$3).toAttribute(s, i.type); + ((this._$Em = t), + null == h ? this.removeAttribute(e) : this.setAttribute(e, h), + (this._$Em = null)); + } + } + _$AK(t, s) { + const i = this.constructor, + e = i._$Eh.get(t); + if (void 0 !== e && this._$Em !== e) { + const t = i.getPropertyOptions(e), + h = + "function" == typeof t.converter + ? { fromAttribute: t.converter } + : void 0 !== t.converter?.fromAttribute + ? t.converter + : u$3; + this._$Em = e; + const r = h.fromAttribute(s, t.type); + ((this[e] = r ?? this._$Ej?.get(e) ?? r), (this._$Em = null)); + } + } + requestUpdate(t, s, i, e = !1, h) { + if (void 0 !== t) { + const r = this.constructor; + if ( + (!1 === e && (h = this[t]), + (i ??= r.getPropertyOptions(t)), + !( + (i.hasChanged ?? f$3)(h, s) || + (i.useDefault && i.reflect && h === this._$Ej?.get(t) && !this.hasAttribute(r._$Eu(t, i))) + )) + ) + return; + this.C(t, s, i); + } + !1 === this.isUpdatePending && (this._$ES = this._$EP()); + } + C(t, s, { useDefault: i, reflect: e, wrapped: h }, r) { + (i && + !(this._$Ej ??= /* @__PURE__ */ new Map()).has(t) && + (this._$Ej.set(t, r ?? s ?? this[t]), !0 !== h || void 0 !== r)) || + (this._$AL.has(t) || (this.hasUpdated || i || (s = void 0), this._$AL.set(t, s)), + !0 === e && this._$Em !== t && (this._$Eq ??= /* @__PURE__ */ new Set()).add(t)); + } + async _$EP() { + this.isUpdatePending = !0; + try { + await this._$ES; + } catch (t) { + Promise.reject(t); + } + const t = this.scheduleUpdate(); + return (null != t && (await t), !this.isUpdatePending); + } + scheduleUpdate() { + return this.performUpdate(); + } + performUpdate() { + if (!this.isUpdatePending) return; + if (!this.hasUpdated) { + if (((this.renderRoot ??= this.createRenderRoot()), this._$Ep)) { + for (const [t, s] of this._$Ep) this[t] = s; + this._$Ep = void 0; + } + const t = this.constructor.elementProperties; + if (t.size > 0) + for (const [s, i] of t) { + const { wrapped: t } = i, + e = this[s]; + !0 !== t || this._$AL.has(s) || void 0 === e || this.C(s, void 0, i, e); + } + } + let t = !1; + const s = this._$AL; + try { + ((t = this.shouldUpdate(s)), + t + ? (this.willUpdate(s), this._$EO?.forEach((t) => t.hostUpdate?.()), this.update(s)) + : this._$EM()); + } catch (s) { + throw ((t = !1), this._$EM(), s); + } + t && this._$AE(s); + } + willUpdate(t) {} + _$AE(t) { + (this._$EO?.forEach((t) => t.hostUpdated?.()), + this.hasUpdated || ((this.hasUpdated = !0), this.firstUpdated(t)), + this.updated(t)); + } + _$EM() { + ((this._$AL = /* @__PURE__ */ new Map()), (this.isUpdatePending = !1)); + } + get updateComplete() { + return this.getUpdateComplete(); + } + getUpdateComplete() { + return this._$ES; + } + shouldUpdate(t) { + return !0; + } + update(t) { + ((this._$Eq &&= this._$Eq.forEach((t) => this._$ET(t, this[t]))), this._$EM()); + } + updated(t) {} + firstUpdated(t) {} +}; +((y$1.elementStyles = []), + (y$1.shadowRootOptions = { mode: "open" }), + (y$1[d$2("elementProperties")] = /* @__PURE__ */ new Map()), + (y$1[d$2("finalized")] = /* @__PURE__ */ new Map()), + p$2?.({ ReactiveElement: y$1 }), + (a$1.reactiveElementVersions ??= []).push("2.1.2")); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$5 = globalThis, + i$7 = (t) => t, + s$7 = t$5.trustedTypes, + e$11 = s$7 ? s$7.createPolicy("lit-html", { createHTML: (t) => t }) : void 0, + h$5 = "$lit$", + o$12 = `lit$${Math.random().toFixed(9).slice(2)}$`, + n$10 = "?" + o$12, + r$9 = `<${n$10}>`, + l$3 = document, + c$4 = () => l$3.createComment(""), + a = (t) => null === t || ("object" != typeof t && "function" != typeof t), + u$2 = Array.isArray, + d$1 = (t) => u$2(t) || "function" == typeof t?.[Symbol.iterator], + f$2 = "[ \n\f\r]", + v$1 = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, + _ = /-->/g, + m$2 = />/g, + p$1 = RegExp(`>|${f$2}(?:([^\\s"'>=/]+)(${f$2}*=${f$2}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`, "g"), + g = /'/g, + $ = /"/g, + y = /^(?:script|style|textarea|title)$/i, + x = + (t) => + (i, ...s) => ({ + _$litType$: t, + strings: i, + values: s, + }), + b = x(1), + w = x(2); +x(3); +const E = Symbol.for("lit-noChange"), + A = Symbol.for("lit-nothing"), + C = /* @__PURE__ */ new WeakMap(), + P = l$3.createTreeWalker(l$3, 129); +function V(t, i) { + if (!u$2(t) || !t.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return void 0 !== e$11 ? e$11.createHTML(i) : i; +} +const N = (t, i) => { + const s = t.length - 1, + e = []; + let n, + l = 2 === i ? "" : 3 === i ? "" : "", + c = v$1; + for (let i = 0; i < s; i++) { + const s = t[i]; + let a, + u, + d = -1, + f = 0; + for (; f < s.length && ((c.lastIndex = f), (u = c.exec(s)), null !== u); ) + ((f = c.lastIndex), + c === v$1 + ? "!--" === u[1] + ? (c = _) + : void 0 !== u[1] + ? (c = m$2) + : void 0 !== u[2] + ? (y.test(u[2]) && (n = RegExp("" === u[0] + ? ((c = n ?? v$1), (d = -1)) + : void 0 === u[1] + ? (d = -2) + : ((d = c.lastIndex - u[2].length), + (a = u[1]), + (c = void 0 === u[3] ? p$1 : '"' === u[3] ? $ : g)) + : c === $ || c === g + ? (c = p$1) + : c === _ || c === m$2 + ? (c = v$1) + : ((c = p$1), (n = void 0))); + const x = c === p$1 && t[i + 1].startsWith("/>") ? " " : ""; + l += + c === v$1 + ? s + r$9 + : d >= 0 + ? (e.push(a), s.slice(0, d) + h$5 + s.slice(d) + o$12 + x) + : s + o$12 + (-2 === d ? i : x); + } + return [V(t, l + (t[s] || "") + (2 === i ? "" : 3 === i ? "" : "")), e]; +}; +var S = class S { + constructor({ strings: t, _$litType$: i }, e) { + let r; + this.parts = []; + let l = 0, + a = 0; + const u = t.length - 1, + d = this.parts, + [f, v] = N(t, i); + if ( + ((this.el = S.createElement(f, e)), (P.currentNode = this.el.content), 2 === i || 3 === i) + ) { + const t = this.el.content.firstChild; + t.replaceWith(...t.childNodes); + } + for (; null !== (r = P.nextNode()) && d.length < u; ) { + if (1 === r.nodeType) { + if (r.hasAttributes()) + for (const t of r.getAttributeNames()) + if (t.endsWith(h$5)) { + const i = v[a++], + s = r.getAttribute(t).split(o$12), + e = /([.?@])?(.*)/.exec(i); + (d.push({ + type: 1, + index: l, + name: e[2], + strings: s, + ctor: "." === e[1] ? I : "?" === e[1] ? L : "@" === e[1] ? z : H, + }), + r.removeAttribute(t)); + } else + t.startsWith(o$12) && + (d.push({ + type: 6, + index: l, + }), + r.removeAttribute(t)); + if (y.test(r.tagName)) { + const t = r.textContent.split(o$12), + i = t.length - 1; + if (i > 0) { + r.textContent = s$7 ? s$7.emptyScript : ""; + for (let s = 0; s < i; s++) + (r.append(t[s], c$4()), + P.nextNode(), + d.push({ + type: 2, + index: ++l, + })); + r.append(t[i], c$4()); + } + } + } else if (8 === r.nodeType) + if (r.data === n$10) + d.push({ + type: 2, + index: l, + }); + else { + let t = -1; + for (; -1 !== (t = r.data.indexOf(o$12, t + 1)); ) + (d.push({ + type: 7, + index: l, + }), + (t += o$12.length - 1)); + } + l++; + } + } + static createElement(t, i) { + const s = l$3.createElement("template"); + return ((s.innerHTML = t), s); + } +}; +function M$1(t, i, s = t, e) { + if (i === E) return i; + let h = void 0 !== e ? s._$Co?.[e] : s._$Cl; + const o = a(i) ? void 0 : i._$litDirective$; + return ( + h?.constructor !== o && + (h?._$AO?.(!1), + void 0 === o ? (h = void 0) : ((h = new o(t)), h._$AT(t, s, e)), + void 0 !== e ? ((s._$Co ??= [])[e] = h) : (s._$Cl = h)), + void 0 !== h && (i = M$1(t, h._$AS(t, i.values), h, e)), + i + ); +} +var R = class { + constructor(t, i) { + ((this._$AV = []), (this._$AN = void 0), (this._$AD = t), (this._$AM = i)); + } + get parentNode() { + return this._$AM.parentNode; + } + get _$AU() { + return this._$AM._$AU; + } + u(t) { + const { + el: { content: i }, + parts: s, + } = this._$AD, + e = (t?.creationScope ?? l$3).importNode(i, !0); + P.currentNode = e; + let h = P.nextNode(), + o = 0, + n = 0, + r = s[0]; + for (; void 0 !== r; ) { + if (o === r.index) { + let i; + (2 === r.type + ? (i = new k(h, h.nextSibling, this, t)) + : 1 === r.type + ? (i = new r.ctor(h, r.name, r.strings, this, t)) + : 6 === r.type && (i = new Z(h, this, t)), + this._$AV.push(i), + (r = s[++n])); + } + o !== r?.index && ((h = P.nextNode()), o++); + } + return ((P.currentNode = l$3), e); + } + p(t) { + let i = 0; + for (const s of this._$AV) + (void 0 !== s && + (void 0 !== s.strings ? (s._$AI(t, s, i), (i += s.strings.length - 2)) : s._$AI(t[i])), + i++); + } +}; +var k = class k { + get _$AU() { + return this._$AM?._$AU ?? this._$Cv; + } + constructor(t, i, s, e) { + ((this.type = 2), + (this._$AH = A), + (this._$AN = void 0), + (this._$AA = t), + (this._$AB = i), + (this._$AM = s), + (this.options = e), + (this._$Cv = e?.isConnected ?? !0)); + } + get parentNode() { + let t = this._$AA.parentNode; + const i = this._$AM; + return (void 0 !== i && 11 === t?.nodeType && (t = i.parentNode), t); + } + get startNode() { + return this._$AA; + } + get endNode() { + return this._$AB; + } + _$AI(t, i = this) { + ((t = M$1(this, t, i)), + a(t) + ? t === A || null == t || "" === t + ? (this._$AH !== A && this._$AR(), (this._$AH = A)) + : t !== this._$AH && t !== E && this._(t) + : void 0 !== t._$litType$ + ? this.$(t) + : void 0 !== t.nodeType + ? this.T(t) + : d$1(t) + ? this.k(t) + : this._(t)); + } + O(t) { + return this._$AA.parentNode.insertBefore(t, this._$AB); + } + T(t) { + this._$AH !== t && (this._$AR(), (this._$AH = this.O(t))); + } + _(t) { + (this._$AH !== A && a(this._$AH) + ? (this._$AA.nextSibling.data = t) + : this.T(l$3.createTextNode(t)), + (this._$AH = t)); + } + $(t) { + const { values: i, _$litType$: s } = t, + e = + "number" == typeof s + ? this._$AC(t) + : (void 0 === s.el && (s.el = S.createElement(V(s.h, s.h[0]), this.options)), s); + if (this._$AH?._$AD === e) this._$AH.p(i); + else { + const t = new R(e, this), + s = t.u(this.options); + (t.p(i), this.T(s), (this._$AH = t)); + } + } + _$AC(t) { + let i = C.get(t.strings); + return (void 0 === i && C.set(t.strings, (i = new S(t))), i); + } + k(t) { + u$2(this._$AH) || ((this._$AH = []), this._$AR()); + const i = this._$AH; + let s, + e = 0; + for (const h of t) + (e === i.length + ? i.push((s = new k(this.O(c$4()), this.O(c$4()), this, this.options))) + : (s = i[e]), + s._$AI(h), + e++); + e < i.length && (this._$AR(s && s._$AB.nextSibling, e), (i.length = e)); + } + _$AR(t = this._$AA.nextSibling, s) { + for (this._$AP?.(!1, !0, s); t !== this._$AB; ) { + const s = i$7(t).nextSibling; + (i$7(t).remove(), (t = s)); + } + } + setConnected(t) { + void 0 === this._$AM && ((this._$Cv = t), this._$AP?.(t)); + } +}; +var H = class { + get tagName() { + return this.element.tagName; + } + get _$AU() { + return this._$AM._$AU; + } + constructor(t, i, s, e, h) { + ((this.type = 1), + (this._$AH = A), + (this._$AN = void 0), + (this.element = t), + (this.name = i), + (this._$AM = e), + (this.options = h), + s.length > 2 || "" !== s[0] || "" !== s[1] + ? ((this._$AH = Array(s.length - 1).fill(/* @__PURE__ */ new String())), (this.strings = s)) + : (this._$AH = A)); + } + _$AI(t, i = this, s, e) { + const h = this.strings; + let o = !1; + if (void 0 === h) + ((t = M$1(this, t, i, 0)), (o = !a(t) || (t !== this._$AH && t !== E)), o && (this._$AH = t)); + else { + const e = t; + let n, r; + for (t = h[0], n = 0; n < h.length - 1; n++) + ((r = M$1(this, e[s + n], i, n)), + r === E && (r = this._$AH[n]), + (o ||= !a(r) || r !== this._$AH[n]), + r === A ? (t = A) : t !== A && (t += (r ?? "") + h[n + 1]), + (this._$AH[n] = r)); + } + o && !e && this.j(t); + } + j(t) { + t === A + ? this.element.removeAttribute(this.name) + : this.element.setAttribute(this.name, t ?? ""); + } +}; +var I = class extends H { + constructor() { + (super(...arguments), (this.type = 3)); + } + j(t) { + this.element[this.name] = t === A ? void 0 : t; + } +}; +var L = class extends H { + constructor() { + (super(...arguments), (this.type = 4)); + } + j(t) { + this.element.toggleAttribute(this.name, !!t && t !== A); + } +}; +var z = class extends H { + constructor(t, i, s, e, h) { + (super(t, i, s, e, h), (this.type = 5)); + } + _$AI(t, i = this) { + if ((t = M$1(this, t, i, 0) ?? A) === E) return; + const s = this._$AH, + e = + (t === A && s !== A) || + t.capture !== s.capture || + t.once !== s.once || + t.passive !== s.passive, + h = t !== A && (s === A || e); + (e && this.element.removeEventListener(this.name, this, s), + h && this.element.addEventListener(this.name, this, t), + (this._$AH = t)); + } + handleEvent(t) { + "function" == typeof this._$AH + ? this._$AH.call(this.options?.host ?? this.element, t) + : this._$AH.handleEvent(t); + } +}; +var Z = class { + constructor(t, i, s) { + ((this.element = t), + (this.type = 6), + (this._$AN = void 0), + (this._$AM = i), + (this.options = s)); + } + get _$AU() { + return this._$AM._$AU; + } + _$AI(t) { + M$1(this, t); + } +}; +const j$1 = { + M: h$5, + P: o$12, + A: n$10, + C: 1, + L: N, + R, + D: d$1, + V: M$1, + I: k, + H, + N: L, + U: z, + B: I, + F: Z, + }, + B = t$5.litHtmlPolyfillSupport; +(B?.(S, k), (t$5.litHtmlVersions ??= []).push("3.3.2")); +const D = (t, i, s) => { + const e = s?.renderBefore ?? i; + let h = e._$litPart$; + if (void 0 === h) { + const t = s?.renderBefore ?? null; + e._$litPart$ = h = new k(i.insertBefore(c$4(), t), t, void 0, s ?? {}); + } + return (h._$AI(t), h); +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const s$6 = globalThis; +var i$6 = class extends y$1 { + constructor() { + (super(...arguments), (this.renderOptions = { host: this }), (this._$Do = void 0)); + } + createRenderRoot() { + const t = super.createRenderRoot(); + return ((this.renderOptions.renderBefore ??= t.firstChild), t); + } + update(t) { + const r = this.render(); + (this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), + super.update(t), + (this._$Do = D(r, this.renderRoot, this.renderOptions))); + } + connectedCallback() { + (super.connectedCallback(), this._$Do?.setConnected(!0)); + } + disconnectedCallback() { + (super.disconnectedCallback(), this._$Do?.setConnected(!1)); + } + render() { + return E; + } +}; +((i$6._$litElement$ = !0), + (i$6["finalized"] = !0), + s$6.litElementHydrateSupport?.({ LitElement: i$6 })); +const o$11 = s$6.litElementPolyfillSupport; +o$11?.({ LitElement: i$6 }); +(s$6.litElementVersions ??= []).push("4.2.2"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$4 = { + ATTRIBUTE: 1, + CHILD: 2, + PROPERTY: 3, + BOOLEAN_ATTRIBUTE: 4, + EVENT: 5, + ELEMENT: 6, + }, + e$10 = + (t) => + (...e) => ({ + _$litDirective$: t, + values: e, + }); +var i$5 = class { + constructor(t) {} + get _$AU() { + return this._$AM._$AU; + } + _$AT(t, e, i) { + ((this._$Ct = t), (this._$AM = e), (this._$Ci = i)); + } + _$AS(t, e) { + return this.update(t, e); + } + update(t, e) { + return this.render(...e); + } +}; +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const { I: t$3 } = j$1, + i$4 = (o) => o, + r$8 = (o) => void 0 === o.strings, + s$5 = () => document.createComment(""), + v = (o, n, e) => { + const l = o._$AA.parentNode, + d = void 0 === n ? o._$AB : n._$AA; + if (void 0 === e) e = new t$3(l.insertBefore(s$5(), d), l.insertBefore(s$5(), d), o, o.options); + else { + const t = e._$AB.nextSibling, + n = e._$AM, + c = n !== o; + if (c) { + let t; + (e._$AQ?.(o), (e._$AM = o), void 0 !== e._$AP && (t = o._$AU) !== n._$AU && e._$AP(t)); + } + if (t !== d || c) { + let o = e._$AA; + for (; o !== t; ) { + const t = i$4(o).nextSibling; + (i$4(l).insertBefore(o, d), (o = t)); + } + } + } + return e; + }, + u$1 = (o, t, i = o) => (o._$AI(t, i), o), + m$1 = {}, + p = (o, t = m$1) => (o._$AH = t), + M = (o) => o._$AH, + h$4 = (o) => { + (o._$AR(), o._$AA.remove()); + }; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const u = (e, s, t) => { + const r = /* @__PURE__ */ new Map(); + for (let l = s; l <= t; l++) r.set(e[l], l); + return r; + }, + c$2 = e$10( + class extends i$5 { + constructor(e) { + if ((super(e), e.type !== t$4.CHILD)) + throw Error("repeat() can only be used in text expressions"); + } + dt(e, s, t) { + let r; + void 0 === t ? (t = s) : void 0 !== s && (r = s); + const l = [], + o = []; + let i = 0; + for (const s of e) ((l[i] = r ? r(s, i) : i), (o[i] = t(s, i)), i++); + return { + values: o, + keys: l, + }; + } + render(e, s, t) { + return this.dt(e, s, t).values; + } + update(s, [t, r, c]) { + const d = M(s), + { values: p$3, keys: a } = this.dt(t, r, c); + if (!Array.isArray(d)) return ((this.ut = a), p$3); + const h = (this.ut ??= []), + v$2 = []; + let m, + y, + x = 0, + j = d.length - 1, + k = 0, + w = p$3.length - 1; + for (; x <= j && k <= w; ) + if (null === d[x]) x++; + else if (null === d[j]) j--; + else if (h[x] === a[k]) ((v$2[k] = u$1(d[x], p$3[k])), x++, k++); + else if (h[j] === a[w]) ((v$2[w] = u$1(d[j], p$3[w])), j--, w--); + else if (h[x] === a[w]) ((v$2[w] = u$1(d[x], p$3[w])), v(s, v$2[w + 1], d[x]), x++, w--); + else if (h[j] === a[k]) ((v$2[k] = u$1(d[j], p$3[k])), v(s, d[x], d[j]), j--, k++); + else if ((void 0 === m && ((m = u(a, k, w)), (y = u(h, x, j))), m.has(h[x]))) + if (m.has(h[j])) { + const e = y.get(a[k]), + t = void 0 !== e ? d[e] : null; + if (null === t) { + const e = v(s, d[x]); + (u$1(e, p$3[k]), (v$2[k] = e)); + } else ((v$2[k] = u$1(t, p$3[k])), v(s, d[x], t), (d[e] = null)); + k++; + } else (h$4(d[j]), j--); + else (h$4(d[x]), x++); + for (; k <= w; ) { + const e = v(s, v$2[w + 1]); + (u$1(e, p$3[k]), (v$2[k++] = e)); + } + for (; x <= j; ) { + const e = d[x++]; + null !== e && h$4(e); + } + return ((this.ut = a), p(s, v$2), E); + } + }, + ); +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var s$4 = class extends Event { + constructor(s, t, e, o) { + (super("context-request", { + bubbles: !0, + composed: !0, + }), + (this.context = s), + (this.contextTarget = t), + (this.callback = e), + (this.subscribe = o ?? !1)); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function n$7(n) { + return n; +} +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ var s$3 = class { + constructor(t, s, i, h) { + if ( + ((this.subscribe = !1), + (this.provided = !1), + (this.value = void 0), + (this.t = (t, s) => { + (this.unsubscribe && + (this.unsubscribe !== s && ((this.provided = !1), this.unsubscribe()), + this.subscribe || this.unsubscribe()), + (this.value = t), + this.host.requestUpdate(), + (this.provided && !this.subscribe) || + ((this.provided = !0), this.callback && this.callback(t, s)), + (this.unsubscribe = s)); + }), + (this.host = t), + void 0 !== s.context) + ) { + const t = s; + ((this.context = t.context), + (this.callback = t.callback), + (this.subscribe = t.subscribe ?? !1)); + } else ((this.context = s), (this.callback = i), (this.subscribe = h ?? !1)); + this.host.addController(this); + } + hostConnected() { + this.dispatchRequest(); + } + hostDisconnected() { + this.unsubscribe && (this.unsubscribe(), (this.unsubscribe = void 0)); + } + dispatchRequest() { + this.host.dispatchEvent(new s$4(this.context, this.host, this.t, this.subscribe)); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +var s$2 = class { + get value() { + return this.o; + } + set value(s) { + this.setValue(s); + } + setValue(s, t = !1) { + const i = t || !Object.is(s, this.o); + ((this.o = s), i && this.updateObservers()); + } + constructor(s) { + ((this.subscriptions = /* @__PURE__ */ new Map()), + (this.updateObservers = () => { + for (const [s, { disposer: t }] of this.subscriptions) s(this.o, t); + }), + void 0 !== s && (this.value = s)); + } + addCallback(s, t, i) { + if (!i) return void s(this.value); + this.subscriptions.has(s) || + this.subscriptions.set(s, { + disposer: () => { + this.subscriptions.delete(s); + }, + consumerHost: t, + }); + const { disposer: h } = this.subscriptions.get(s); + s(this.value, h); + } + clearCallbacks() { + this.subscriptions.clear(); + } +}; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ var e$8 = class extends Event { + constructor(t, s) { + (super("context-provider", { + bubbles: !0, + composed: !0, + }), + (this.context = t), + (this.contextTarget = s)); + } +}; +var i$3 = class extends s$2 { + constructor(s, e, i) { + (super(void 0 !== e.context ? e.initialValue : i), + (this.onContextRequest = (t) => { + if (t.context !== this.context) return; + const s = t.contextTarget ?? t.composedPath()[0]; + s !== this.host && (t.stopPropagation(), this.addCallback(t.callback, s, t.subscribe)); + }), + (this.onProviderRequest = (s) => { + if (s.context !== this.context) return; + if ((s.contextTarget ?? s.composedPath()[0]) === this.host) return; + const e = /* @__PURE__ */ new Set(); + for (const [s, { consumerHost: i }] of this.subscriptions) + e.has(s) || (e.add(s), i.dispatchEvent(new s$4(this.context, i, s, !0))); + s.stopPropagation(); + }), + (this.host = s), + void 0 !== e.context ? (this.context = e.context) : (this.context = e), + this.attachListeners(), + this.host.addController?.(this)); + } + attachListeners() { + (this.host.addEventListener("context-request", this.onContextRequest), + this.host.addEventListener("context-provider", this.onProviderRequest)); + } + hostConnected() { + this.host.dispatchEvent(new e$8(this.context, this.host)); + } +}; +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function c$1({ context: c, subscribe: e }) { + return (o, n) => { + "object" == typeof n + ? n.addInitializer(function () { + new s$3(this, { + context: c, + callback: (t) => { + o.set.call(this, t); + }, + subscribe: e, + }); + }) + : o.constructor.addInitializer((o) => { + new s$3(o, { + context: c, + callback: (t) => { + o[n] = t; + }, + subscribe: e, + }); + }); + }; +} +const eventInit = { + bubbles: true, + cancelable: true, + composed: true, +}; +var StateEvent = class StateEvent extends CustomEvent { + static { + this.eventName = "a2uiaction"; + } + constructor(payload) { + super(StateEvent.eventName, { + detail: payload, + ...eventInit, + }); + this.payload = payload; + } +}; +const opacityBehavior = ` + &:not([disabled]) { + cursor: pointer; + opacity: var(--opacity, 0); + transition: opacity var(--speed, 0.2s) cubic-bezier(0, 0, 0.3, 1); + + &:hover, + &:focus { + opacity: 1; + } + }`; +const behavior = ` + ${new Array(21) + .fill(0) + .map((_, idx) => { + return `.behavior-ho-${idx * 5} { + --opacity: ${idx / 20}; + ${opacityBehavior} + }`; + }) + .join("\n")} + + .behavior-o-s { + overflow: scroll; + } + + .behavior-o-a { + overflow: auto; + } + + .behavior-o-h { + overflow: hidden; + } + + .behavior-sw-n { + scrollbar-width: none; + } +`; +const border = ` + ${new Array(25) + .fill(0) + .map((_, idx) => { + return ` + .border-bw-${idx} { border-width: ${idx}px; } + .border-btw-${idx} { border-top-width: ${idx}px; } + .border-bbw-${idx} { border-bottom-width: ${idx}px; } + .border-blw-${idx} { border-left-width: ${idx}px; } + .border-brw-${idx} { border-right-width: ${idx}px; } + + .border-ow-${idx} { outline-width: ${idx}px; } + .border-br-${idx} { border-radius: ${idx * 4}px; overflow: hidden;}`; + }) + .join("\n")} + + .border-br-50pc { + border-radius: 50%; + } + + .border-bs-s { + border-style: solid; + } +`; +const shades = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]; +function merge(...classes) { + const styles = {}; + for (const clazz of classes) + for (const [key, val] of Object.entries(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + const existingKeys = Object.keys(styles).filter((key) => key.startsWith(prefix)); + for (const existingKey of existingKeys) delete styles[existingKey]; + styles[key] = val; + } + return styles; +} +function appendToAll(target, exclusions, ...classes) { + const updatedTarget = structuredClone(target); + for (const clazz of classes) + for (const key of Object.keys(clazz)) { + const prefix = key.split("-").with(-1, "").join("-"); + for (const [tagName, classesToAdd] of Object.entries(updatedTarget)) { + if (exclusions.includes(tagName)) continue; + let found = false; + for (let t = 0; t < classesToAdd.length; t++) + if (classesToAdd[t].startsWith(prefix)) { + found = true; + classesToAdd[t] = key; + } + if (!found) classesToAdd.push(key); + } + } + return updatedTarget; +} +function toProp(key) { + if (key.startsWith("nv")) return `--nv-${key.slice(2)}`; + return `--${key[0]}-${key.slice(1)}`; +} +const color = (src) => ` + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + return `.color-bc-${key} { border-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; + }) + .join("\n")} + + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + const vals = [ + `.color-bgc-${key} { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, + `.color-bbgc-${key}::backdrop { background-color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`, + ]; + for (let o = 0.1; o < 1; o += 0.1) + vals.push(`.color-bbgc-${key}_${(o * 100).toFixed(0)}::backdrop { + background-color: light-dark(oklch(from var(${toProp(key)}) l c h / calc(alpha * ${o.toFixed(1)})), oklch(from var(${toProp(inverseKey)}) l c h / calc(alpha * ${o.toFixed(1)})) ); + } + `); + return vals.join("\n"); + }) + .join("\n")} + + ${src + .map((key) => { + const inverseKey = getInverseKey(key); + return `.color-c-${key} { color: light-dark(var(${toProp(key)}), var(${toProp(inverseKey)})); }`; + }) + .join("\n")} + `; +const getInverseKey = (key) => { + const match = key.match(/^([a-z]+)(\d+)$/); + if (!match) return key; + const [, prefix, shadeStr] = match; + const target = 100 - parseInt(shadeStr, 10); + return `${prefix}${shades.reduce((prev, curr) => (Math.abs(curr - target) < Math.abs(prev - target) ? curr : prev))}`; +}; +const keyFactory = (prefix) => { + return shades.map((v) => `${prefix}${v}`); +}; +const structuralStyles$1 = [ + behavior, + border, + [ + color(keyFactory("p")), + color(keyFactory("s")), + color(keyFactory("t")), + color(keyFactory("n")), + color(keyFactory("nv")), + color(keyFactory("e")), + ` + .color-bgc-transparent { + background-color: transparent; + } + + :host { + color-scheme: var(--color-scheme); + } + `, + ], + ` + .g-icon { + font-family: "Material Symbols Outlined", "Google Symbols"; + font-weight: normal; + font-style: normal; + font-display: optional; + font-size: 20px; + width: 1em; + height: 1em; + user-select: none; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: "liga"; + -webkit-font-smoothing: antialiased; + overflow: hidden; + + font-variation-settings: "FILL" 0, "wght" 300, "GRAD" 0, "opsz" 48, + "ROND" 100; + + &.filled { + font-variation-settings: "FILL" 1, "wght" 300, "GRAD" 0, "opsz" 48, + "ROND" 100; + } + + &.filled-heavy { + font-variation-settings: "FILL" 1, "wght" 700, "GRAD" 0, "opsz" 48, + "ROND" 100; + } + } +`, + ` + :host { + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `--g-${idx + 1}: ${(idx + 1) * 4}px;`; + }) + .join("\n")} + } + + ${new Array(49) + .fill(0) + .map((_, index) => { + const idx = index - 24; + const lbl = idx < 0 ? `n${Math.abs(idx)}` : idx.toString(); + return ` + .layout-p-${lbl} { --padding: ${idx * 4}px; padding: var(--padding); } + .layout-pt-${lbl} { padding-top: ${idx * 4}px; } + .layout-pr-${lbl} { padding-right: ${idx * 4}px; } + .layout-pb-${lbl} { padding-bottom: ${idx * 4}px; } + .layout-pl-${lbl} { padding-left: ${idx * 4}px; } + + .layout-m-${lbl} { --margin: ${idx * 4}px; margin: var(--margin); } + .layout-mt-${lbl} { margin-top: ${idx * 4}px; } + .layout-mr-${lbl} { margin-right: ${idx * 4}px; } + .layout-mb-${lbl} { margin-bottom: ${idx * 4}px; } + .layout-ml-${lbl} { margin-left: ${idx * 4}px; } + + .layout-t-${lbl} { top: ${idx * 4}px; } + .layout-r-${lbl} { right: ${idx * 4}px; } + .layout-b-${lbl} { bottom: ${idx * 4}px; } + .layout-l-${lbl} { left: ${idx * 4}px; }`; + }) + .join("\n")} + + ${new Array(25) + .fill(0) + .map((_, idx) => { + return ` + .layout-g-${idx} { gap: ${idx * 4}px; }`; + }) + .join("\n")} + + ${new Array(8) + .fill(0) + .map((_, idx) => { + return ` + .layout-grd-col${idx + 1} { grid-template-columns: ${"1fr ".repeat(idx + 1).trim()}; }`; + }) + .join("\n")} + + .layout-pos-a { + position: absolute; + } + + .layout-pos-rel { + position: relative; + } + + .layout-dsp-none { + display: none; + } + + .layout-dsp-block { + display: block; + } + + .layout-dsp-grid { + display: grid; + } + + .layout-dsp-iflex { + display: inline-flex; + } + + .layout-dsp-flexvert { + display: flex; + flex-direction: column; + } + + .layout-dsp-flexhor { + display: flex; + flex-direction: row; + } + + .layout-fw-w { + flex-wrap: wrap; + } + + .layout-al-fs { + align-items: start; + } + + .layout-al-fe { + align-items: end; + } + + .layout-al-c { + align-items: center; + } + + .layout-as-n { + align-self: normal; + } + + .layout-js-c { + justify-self: center; + } + + .layout-sp-c { + justify-content: center; + } + + .layout-sp-ev { + justify-content: space-evenly; + } + + .layout-sp-bt { + justify-content: space-between; + } + + .layout-sp-s { + justify-content: start; + } + + .layout-sp-e { + justify-content: end; + } + + .layout-ji-e { + justify-items: end; + } + + .layout-r-none { + resize: none; + } + + .layout-fs-c { + field-sizing: content; + } + + .layout-fs-n { + field-sizing: none; + } + + .layout-flx-0 { + flex: 0 0 auto; + } + + .layout-flx-1 { + flex: 1 0 auto; + } + + .layout-c-s { + contain: strict; + } + + /** Widths **/ + + ${new Array(10) + .fill(0) + .map((_, idx) => { + const weight = (idx + 1) * 10; + return `.layout-w-${weight} { width: ${weight}%; max-width: ${weight}%; }`; + }) + .join("\n")} + + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `.layout-wp-${idx} { width: ${idx * 4}px; }`; + }) + .join("\n")} + + /** Heights **/ + + ${new Array(10) + .fill(0) + .map((_, idx) => { + const height = (idx + 1) * 10; + return `.layout-h-${height} { height: ${height}%; }`; + }) + .join("\n")} + + ${new Array(16) + .fill(0) + .map((_, idx) => { + return `.layout-hp-${idx} { height: ${idx * 4}px; }`; + }) + .join("\n")} + + .layout-el-cv { + & img, + & video { + width: 100%; + height: 100%; + object-fit: cover; + margin: 0; + } + } + + .layout-ar-sq { + aspect-ratio: 1 / 1; + } + + .layout-ex-fb { + margin: calc(var(--padding) * -1) 0 0 calc(var(--padding) * -1); + width: calc(100% + var(--padding) * 2); + height: calc(100% + var(--padding) * 2); + } +`, + ` + ${new Array(21) + .fill(0) + .map((_, idx) => { + return `.opacity-el-${idx * 5} { opacity: ${idx / 20}; }`; + }) + .join("\n")} +`, + ` + :host { + --default-font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + --default-font-family-mono: "Courier New", Courier, monospace; + } + + .typography-f-s { + font-family: var(--font-family, var(--default-font-family)); + font-optical-sizing: auto; + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0; + } + + .typography-f-sf { + font-family: var(--font-family-flex, var(--default-font-family)); + font-optical-sizing: auto; + } + + .typography-f-c { + font-family: var(--font-family-mono, var(--default-font-family)); + font-optical-sizing: auto; + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0; + } + + .typography-v-r { + font-variation-settings: "slnt" 0, "wdth" 100, "GRAD" 0, "ROND" 100; + } + + .typography-ta-s { + text-align: start; + } + + .typography-ta-c { + text-align: center; + } + + .typography-fs-n { + font-style: normal; + } + + .typography-fs-i { + font-style: italic; + } + + .typography-sz-ls { + font-size: 11px; + line-height: 16px; + } + + .typography-sz-lm { + font-size: 12px; + line-height: 16px; + } + + .typography-sz-ll { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-bs { + font-size: 12px; + line-height: 16px; + } + + .typography-sz-bm { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-bl { + font-size: 16px; + line-height: 24px; + } + + .typography-sz-ts { + font-size: 14px; + line-height: 20px; + } + + .typography-sz-tm { + font-size: 16px; + line-height: 24px; + } + + .typography-sz-tl { + font-size: 22px; + line-height: 28px; + } + + .typography-sz-hs { + font-size: 24px; + line-height: 32px; + } + + .typography-sz-hm { + font-size: 28px; + line-height: 36px; + } + + .typography-sz-hl { + font-size: 32px; + line-height: 40px; + } + + .typography-sz-ds { + font-size: 36px; + line-height: 44px; + } + + .typography-sz-dm { + font-size: 45px; + line-height: 52px; + } + + .typography-sz-dl { + font-size: 57px; + line-height: 64px; + } + + .typography-ws-p { + white-space: pre-line; + } + + .typography-ws-nw { + white-space: nowrap; + } + + .typography-td-none { + text-decoration: none; + } + + /** Weights **/ + + ${new Array(9) + .fill(0) + .map((_, idx) => { + const weight = (idx + 1) * 100; + return `.typography-w-${weight} { font-weight: ${weight}; }`; + }) + .join("\n")} +`, +] + .flat(Infinity) + .join("\n"); +var guards_exports = /* @__PURE__ */ __exportAll({ + isComponentArrayReference: () => isComponentArrayReference, + isObject: () => isObject$1, + isPath: () => isPath, + isResolvedAudioPlayer: () => isResolvedAudioPlayer, + isResolvedButton: () => isResolvedButton, + isResolvedCard: () => isResolvedCard, + isResolvedCheckbox: () => isResolvedCheckbox, + isResolvedColumn: () => isResolvedColumn, + isResolvedDateTimeInput: () => isResolvedDateTimeInput, + isResolvedDivider: () => isResolvedDivider, + isResolvedIcon: () => isResolvedIcon, + isResolvedImage: () => isResolvedImage, + isResolvedList: () => isResolvedList, + isResolvedModal: () => isResolvedModal, + isResolvedMultipleChoice: () => isResolvedMultipleChoice, + isResolvedRow: () => isResolvedRow, + isResolvedSlider: () => isResolvedSlider, + isResolvedTabs: () => isResolvedTabs, + isResolvedText: () => isResolvedText, + isResolvedTextField: () => isResolvedTextField, + isResolvedVideo: () => isResolvedVideo, + isValueMap: () => isValueMap, +}); +function isValueMap(value) { + return isObject$1(value) && "key" in value; +} +function isPath(key, value) { + return key === "path" && typeof value === "string"; +} +function isObject$1(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isComponentArrayReference(value) { + if (!isObject$1(value)) return false; + return "explicitList" in value || "template" in value; +} +function isStringValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "string") || + "literalString" in value) + ); +} +function isNumberValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "number") || + "literalNumber" in value) + ); +} +function isBooleanValue(value) { + return ( + isObject$1(value) && + ("path" in value || + ("literal" in value && typeof value.literal === "boolean") || + "literalBoolean" in value) + ); +} +function isAnyComponentNode(value) { + if (!isObject$1(value)) return false; + if (!("id" in value && "type" in value && "properties" in value)) return false; + return true; +} +function isResolvedAudioPlayer(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +function isResolvedButton(props) { + return ( + isObject$1(props) && "child" in props && isAnyComponentNode(props.child) && "action" in props + ); +} +function isResolvedCard(props) { + if (!isObject$1(props)) return false; + if (!("child" in props)) + if (!("children" in props)) return false; + else return Array.isArray(props.children) && props.children.every(isAnyComponentNode); + return isAnyComponentNode(props.child); +} +function isResolvedCheckbox(props) { + return ( + isObject$1(props) && + "label" in props && + isStringValue(props.label) && + "value" in props && + isBooleanValue(props.value) + ); +} +function isResolvedColumn(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedDateTimeInput(props) { + return isObject$1(props) && "value" in props && isStringValue(props.value); +} +function isResolvedDivider(props) { + return isObject$1(props); +} +function isResolvedImage(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +function isResolvedIcon(props) { + return isObject$1(props) && "name" in props && isStringValue(props.name); +} +function isResolvedList(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedModal(props) { + return ( + isObject$1(props) && + "entryPointChild" in props && + isAnyComponentNode(props.entryPointChild) && + "contentChild" in props && + isAnyComponentNode(props.contentChild) + ); +} +function isResolvedMultipleChoice(props) { + return isObject$1(props) && "selections" in props; +} +function isResolvedRow(props) { + return ( + isObject$1(props) && + "children" in props && + Array.isArray(props.children) && + props.children.every(isAnyComponentNode) + ); +} +function isResolvedSlider(props) { + return isObject$1(props) && "value" in props && isNumberValue(props.value); +} +function isResolvedTabItem(item) { + return ( + isObject$1(item) && + "title" in item && + isStringValue(item.title) && + "child" in item && + isAnyComponentNode(item.child) + ); +} +function isResolvedTabs(props) { + return ( + isObject$1(props) && + "tabItems" in props && + Array.isArray(props.tabItems) && + props.tabItems.every(isResolvedTabItem) + ); +} +function isResolvedText(props) { + return isObject$1(props) && "text" in props && isStringValue(props.text); +} +function isResolvedTextField(props) { + return isObject$1(props) && "label" in props && isStringValue(props.label); +} +function isResolvedVideo(props) { + return isObject$1(props) && "url" in props && isStringValue(props.url); +} +/** + * Processes and consolidates A2UIProtocolMessage objects into a structured, + * hierarchical model of UI surfaces. + */ +var A2uiMessageProcessor = class A2uiMessageProcessor { + static { + this.DEFAULT_SURFACE_ID = "@default"; + } + #mapCtor = Map; + #arrayCtor = Array; + #setCtor = Set; + #objCtor = Object; + #surfaces; + constructor( + opts = { + mapCtor: Map, + arrayCtor: Array, + setCtor: Set, + objCtor: Object, + }, + ) { + this.opts = opts; + this.#arrayCtor = opts.arrayCtor; + this.#mapCtor = opts.mapCtor; + this.#setCtor = opts.setCtor; + this.#objCtor = opts.objCtor; + this.#surfaces = new opts.mapCtor(); + } + getSurfaces() { + return this.#surfaces; + } + clearSurfaces() { + this.#surfaces.clear(); + } + processMessages(messages) { + for (const message of messages) { + if (message.beginRendering) + this.#handleBeginRendering(message.beginRendering, message.beginRendering.surfaceId); + if (message.surfaceUpdate) + this.#handleSurfaceUpdate(message.surfaceUpdate, message.surfaceUpdate.surfaceId); + if (message.dataModelUpdate) + this.#handleDataModelUpdate(message.dataModelUpdate, message.dataModelUpdate.surfaceId); + if (message.deleteSurface) this.#handleDeleteSurface(message.deleteSurface); + } + } + /** + * Retrieves the data for a given component node and a relative path string. + * This correctly handles the special `.` path, which refers to the node's + * own data context. + */ + getData(node, relativePath, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return null; + let finalPath; + if (relativePath === "." || relativePath === "") finalPath = node.dataContextPath ?? "/"; + else finalPath = this.resolvePath(relativePath, node.dataContextPath); + return this.#getDataByPath(surface.dataModel, finalPath); + } + setData(node, relativePath, value, surfaceId = A2uiMessageProcessor.DEFAULT_SURFACE_ID) { + if (!node) { + console.warn("No component node set"); + return; + } + const surface = this.#getOrCreateSurface(surfaceId); + if (!surface) return; + let finalPath; + if (relativePath === "." || relativePath === "") finalPath = node.dataContextPath ?? "/"; + else finalPath = this.resolvePath(relativePath, node.dataContextPath); + this.#setDataByPath(surface.dataModel, finalPath, value); + } + resolvePath(path, dataContextPath) { + if (path.startsWith("/")) return path; + if (dataContextPath && dataContextPath !== "/") + return dataContextPath.endsWith("/") + ? `${dataContextPath}${path}` + : `${dataContextPath}/${path}`; + return `/${path}`; + } + #parseIfJsonString(value) { + if (typeof value !== "string") return value; + const trimmedValue = value.trim(); + if ( + (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || + (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) + ) + try { + return JSON.parse(value); + } catch (e) { + console.warn(`Failed to parse potential JSON string: "${value.substring(0, 50)}..."`, e); + return value; + } + return value; + } + /** + * Converts a specific array format [{key: "...", value_string: "..."}, ...] + * into a standard Map. It also attempts to parse any string values that + * appear to be stringified JSON. + */ + #convertKeyValueArrayToMap(arr) { + const map = new this.#mapCtor(); + for (const item of arr) { + if (!isObject$1(item) || !("key" in item)) continue; + const key = item.key; + const valueKey = this.#findValueKey(item); + if (!valueKey) continue; + let value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) + value = this.#convertKeyValueArrayToMap(value); + else if (typeof value === "string") value = this.#parseIfJsonString(value); + this.#setDataByPath(map, key, value); + } + return map; + } + #setDataByPath(root, path, value) { + if (Array.isArray(value) && (value.length === 0 || (isObject$1(value[0]) && "key" in value[0]))) + if (value.length === 1 && isObject$1(value[0]) && value[0].key === ".") { + const item = value[0]; + const valueKey = this.#findValueKey(item); + if (valueKey) { + value = item[valueKey]; + if (valueKey === "valueMap" && Array.isArray(value)) + value = this.#convertKeyValueArrayToMap(value); + else if (typeof value === "string") value = this.#parseIfJsonString(value); + } else value = this.#convertKeyValueArrayToMap(value); + } else value = this.#convertKeyValueArrayToMap(value); + const segments = this.#normalizePath(path) + .split("/") + .filter((s) => s); + if (segments.length === 0) { + if (value instanceof Map || isObject$1(value)) { + if (!(value instanceof Map) && isObject$1(value)) + value = new this.#mapCtor(Object.entries(value)); + root.clear(); + for (const [key, v] of value.entries()) root.set(key, v); + } else console.error("Cannot set root of DataModel to a non-Map value."); + return; + } + let current = root; + for (let i = 0; i < segments.length - 1; i++) { + const segment = segments[i]; + let target; + if (current instanceof Map) target = current.get(segment); + else if (Array.isArray(current) && /^\d+$/.test(segment)) + target = current[parseInt(segment, 10)]; + if (target === void 0 || typeof target !== "object" || target === null) { + target = new this.#mapCtor(); + if (current instanceof this.#mapCtor) current.set(segment, target); + else if (Array.isArray(current)) current[parseInt(segment, 10)] = target; + } + current = target; + } + const finalSegment = segments[segments.length - 1]; + const storedValue = value; + if (current instanceof this.#mapCtor) current.set(finalSegment, storedValue); + else if (Array.isArray(current) && /^\d+$/.test(finalSegment)) + current[parseInt(finalSegment, 10)] = storedValue; + } + /** + * Normalizes a path string into a consistent, slash-delimited format. + * Converts bracket notation and dot notation in a two-pass. + * e.g., "bookRecommendations[0].title" -> "/bookRecommendations/0/title" + * e.g., "book.0.title" -> "/book/0/title" + */ + #normalizePath(path) { + return ( + "/" + + path + .replace(/\[(\d+)\]/g, ".$1") + .split(".") + .filter((s) => s.length > 0) + .join("/") + ); + } + #getDataByPath(root, path) { + const segments = this.#normalizePath(path) + .split("/") + .filter((s) => s); + let current = root; + for (const segment of segments) { + if (current === void 0 || current === null) return null; + if (current instanceof Map) current = current.get(segment); + else if (Array.isArray(current) && /^\d+$/.test(segment)) + current = current[parseInt(segment, 10)]; + else if (isObject$1(current)) current = current[segment]; + else return null; + } + return current; + } + #getOrCreateSurface(surfaceId) { + let surface = this.#surfaces.get(surfaceId); + if (!surface) { + surface = new this.#objCtor({ + rootComponentId: null, + componentTree: null, + dataModel: new this.#mapCtor(), + components: new this.#mapCtor(), + styles: new this.#objCtor(), + }); + this.#surfaces.set(surfaceId, surface); + } + return surface; + } + #handleBeginRendering(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + surface.rootComponentId = message.root; + surface.styles = message.styles ?? {}; + this.#rebuildComponentTree(surface); + } + #handleSurfaceUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + for (const component of message.components) surface.components.set(component.id, component); + this.#rebuildComponentTree(surface); + } + #handleDataModelUpdate(message, surfaceId) { + const surface = this.#getOrCreateSurface(surfaceId); + const path = message.path ?? "/"; + this.#setDataByPath(surface.dataModel, path, message.contents); + this.#rebuildComponentTree(surface); + } + #handleDeleteSurface(message) { + this.#surfaces.delete(message.surfaceId); + } + /** + * Starts at the root component of the surface and builds out the tree + * recursively. This process involves resolving all properties of the child + * components, and expanding on any explicit children lists or templates + * found in the structure. + * + * @param surface The surface to be built. + */ + #rebuildComponentTree(surface) { + if (!surface.rootComponentId) { + surface.componentTree = null; + return; + } + const visited = new this.#setCtor(); + surface.componentTree = this.#buildNodeRecursive( + surface.rootComponentId, + surface, + visited, + "/", + "", + ); + } + /** Finds a value key in a map. */ + #findValueKey(value) { + return Object.keys(value).find((k) => k.startsWith("value")); + } + /** + * Builds out the nodes recursively. + */ + #buildNodeRecursive(baseComponentId, surface, visited, dataContextPath, idSuffix = "") { + const fullId = `${baseComponentId}${idSuffix}`; + const { components } = surface; + if (!components.has(baseComponentId)) return null; + if (visited.has(fullId)) throw new Error(`Circular dependency for component "${fullId}".`); + visited.add(fullId); + const componentData = components.get(baseComponentId); + const componentProps = componentData.component ?? {}; + const componentType = Object.keys(componentProps)[0]; + const unresolvedProperties = componentProps[componentType]; + const resolvedProperties = new this.#objCtor(); + if (isObject$1(unresolvedProperties)) + for (const [key, value] of Object.entries(unresolvedProperties)) + resolvedProperties[key] = this.#resolvePropertyValue( + value, + surface, + visited, + dataContextPath, + idSuffix, + key, + ); + visited.delete(fullId); + const baseNode = { + id: fullId, + dataContextPath, + weight: componentData.weight ?? "initial", + }; + switch (componentType) { + case "Text": + if (!isResolvedText(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Text", + properties: resolvedProperties, + }); + case "Image": + if (!isResolvedImage(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Image", + properties: resolvedProperties, + }); + case "Icon": + if (!isResolvedIcon(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Icon", + properties: resolvedProperties, + }); + case "Video": + if (!isResolvedVideo(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Video", + properties: resolvedProperties, + }); + case "AudioPlayer": + if (!isResolvedAudioPlayer(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "AudioPlayer", + properties: resolvedProperties, + }); + case "Row": + if (!isResolvedRow(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Row", + properties: resolvedProperties, + }); + case "Column": + if (!isResolvedColumn(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Column", + properties: resolvedProperties, + }); + case "List": + if (!isResolvedList(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "List", + properties: resolvedProperties, + }); + case "Card": + if (!isResolvedCard(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Card", + properties: resolvedProperties, + }); + case "Tabs": + if (!isResolvedTabs(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Tabs", + properties: resolvedProperties, + }); + case "Divider": + if (!isResolvedDivider(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Divider", + properties: resolvedProperties, + }); + case "Modal": + if (!isResolvedModal(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Modal", + properties: resolvedProperties, + }); + case "Button": + if (!isResolvedButton(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Button", + properties: resolvedProperties, + }); + case "CheckBox": + if (!isResolvedCheckbox(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "CheckBox", + properties: resolvedProperties, + }); + case "TextField": + if (!isResolvedTextField(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "TextField", + properties: resolvedProperties, + }); + case "DateTimeInput": + if (!isResolvedDateTimeInput(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "DateTimeInput", + properties: resolvedProperties, + }); + case "MultipleChoice": + if (!isResolvedMultipleChoice(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "MultipleChoice", + properties: resolvedProperties, + }); + case "Slider": + if (!isResolvedSlider(resolvedProperties)) + throw new Error(`Invalid data; expected ${componentType}`); + return new this.#objCtor({ + ...baseNode, + type: "Slider", + properties: resolvedProperties, + }); + default: + return new this.#objCtor({ + ...baseNode, + type: componentType, + properties: resolvedProperties, + }); + } + } + /** + * Recursively resolves an individual property value. If a property indicates + * a child node (a string that matches a component ID), an explicitList of + * children, or a template, these will be built out here. + */ + #resolvePropertyValue( + value, + surface, + visited, + dataContextPath, + idSuffix = "", + propertyKey = null, + ) { + const isComponentIdReferenceKey = (key) => key === "child" || key.endsWith("Child"); + if ( + typeof value === "string" && + propertyKey && + isComponentIdReferenceKey(propertyKey) && + surface.components.has(value) + ) + return this.#buildNodeRecursive(value, surface, visited, dataContextPath, idSuffix); + if (isComponentArrayReference(value)) { + if (value.explicitList) + return value.explicitList.map((id) => + this.#buildNodeRecursive(id, surface, visited, dataContextPath, idSuffix), + ); + if (value.template) { + const fullDataPath = this.resolvePath(value.template.dataBinding, dataContextPath); + const data = this.#getDataByPath(surface.dataModel, fullDataPath); + const template = value.template; + if (Array.isArray(data)) + return data.map((_, index) => { + const newSuffix = `:${[...dataContextPath.split("/").filter((segment) => /^\d+$/.test(segment)), index].join(":")}`; + const childDataContextPath = `${fullDataPath}/${index}`; + return this.#buildNodeRecursive( + template.componentId, + surface, + visited, + childDataContextPath, + newSuffix, + ); + }); + if (data instanceof this.#mapCtor) + return Array.from(data.keys(), (key) => { + const newSuffix = `:${key}`; + const childDataContextPath = `${fullDataPath}/${key}`; + return this.#buildNodeRecursive( + template.componentId, + surface, + visited, + childDataContextPath, + newSuffix, + ); + }); + return new this.#arrayCtor(); + } + } + if (Array.isArray(value)) + return value.map((item) => + this.#resolvePropertyValue(item, surface, visited, dataContextPath, idSuffix, propertyKey), + ); + if (isObject$1(value)) { + const newObj = new this.#objCtor(); + for (const [key, propValue] of Object.entries(value)) { + let propertyValue = propValue; + if (isPath(key, propValue) && dataContextPath !== "/") { + propertyValue = propValue + .replace(/^\.?\/item/, "") + .replace(/^\.?\/text/, "") + .replace(/^\.?\/label/, "") + .replace(/^\.?\//, ""); + newObj[key] = propertyValue; + continue; + } + newObj[key] = this.#resolvePropertyValue( + propertyValue, + surface, + visited, + dataContextPath, + idSuffix, + key, + ); + } + return newObj; + } + return value; + } +}; +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => + key in obj + ? __defProp(obj, key, { + enumerable: true, + configurable: true, + writable: true, + value, + }) + : (obj[key] = value); +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + return value; +}; +var __accessCheck = (obj, member, msg) => { + if (!member.has(obj)) throw TypeError("Cannot " + msg); +}; +var __privateIn = (member, obj) => { + if (Object(obj) !== obj) throw TypeError('Cannot use the "in" operator on this value'); + return member.has(obj); +}; +var __privateAdd = (obj, member, value) => { + if (member.has(obj)) throw TypeError("Cannot add the same private member more than once"); + member instanceof WeakSet ? member.add(obj) : member.set(obj, value); +}; +var __privateMethod = (obj, member, method) => { + __accessCheck(obj, member, "access private method"); + return method; +}; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function defaultEquals(a, b) { + return Object.is(a, b); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +let activeConsumer = null; +let inNotificationPhase = false; +let epoch = 1; +const SIGNAL = /* @__PURE__ */ Symbol("SIGNAL"); +function setActiveConsumer(consumer) { + const prev = activeConsumer; + activeConsumer = consumer; + return prev; +} +function getActiveConsumer() { + return activeConsumer; +} +function isInNotificationPhase() { + return inNotificationPhase; +} +const REACTIVE_NODE = { + version: 0, + lastCleanEpoch: 0, + dirty: false, + producerNode: void 0, + producerLastReadVersion: void 0, + producerIndexOfThis: void 0, + nextProducerIndex: 0, + liveConsumerNode: void 0, + liveConsumerIndexOfThis: void 0, + consumerAllowSignalWrites: false, + consumerIsAlwaysLive: false, + producerMustRecompute: () => false, + producerRecomputeValue: () => {}, + consumerMarkedDirty: () => {}, + consumerOnSignalRead: () => {}, +}; +function producerAccessed(node) { + if (inNotificationPhase) + throw new Error( + typeof ngDevMode !== "undefined" && ngDevMode + ? `Assertion error: signal read during notification phase` + : "", + ); + if (activeConsumer === null) return; + activeConsumer.consumerOnSignalRead(node); + const idx = activeConsumer.nextProducerIndex++; + assertConsumerNode(activeConsumer); + if (idx < activeConsumer.producerNode.length && activeConsumer.producerNode[idx] !== node) { + if (consumerIsLive(activeConsumer)) { + const staleProducer = activeConsumer.producerNode[idx]; + producerRemoveLiveConsumerAtIndex(staleProducer, activeConsumer.producerIndexOfThis[idx]); + } + } + if (activeConsumer.producerNode[idx] !== node) { + activeConsumer.producerNode[idx] = node; + activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) + ? producerAddLiveConsumer(node, activeConsumer, idx) + : 0; + } + activeConsumer.producerLastReadVersion[idx] = node.version; +} +function producerIncrementEpoch() { + epoch++; +} +function producerUpdateValueVersion(node) { + if (!node.dirty && node.lastCleanEpoch === epoch) return; + if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) { + node.dirty = false; + node.lastCleanEpoch = epoch; + return; + } + node.producerRecomputeValue(node); + node.dirty = false; + node.lastCleanEpoch = epoch; +} +function producerNotifyConsumers(node) { + if (node.liveConsumerNode === void 0) return; + const prev = inNotificationPhase; + inNotificationPhase = true; + try { + for (const consumer of node.liveConsumerNode) if (!consumer.dirty) consumerMarkDirty(consumer); + } finally { + inNotificationPhase = prev; + } +} +function producerUpdatesAllowed() { + return (activeConsumer == null ? void 0 : activeConsumer.consumerAllowSignalWrites) !== false; +} +function consumerMarkDirty(node) { + var _a; + node.dirty = true; + producerNotifyConsumers(node); + (_a = node.consumerMarkedDirty) == null || _a.call(node.wrapper ?? node); +} +function consumerBeforeComputation(node) { + node && (node.nextProducerIndex = 0); + return setActiveConsumer(node); +} +function consumerAfterComputation(node, prevConsumer) { + setActiveConsumer(prevConsumer); + if ( + !node || + node.producerNode === void 0 || + node.producerIndexOfThis === void 0 || + node.producerLastReadVersion === void 0 + ) + return; + if (consumerIsLive(node)) + for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + while (node.producerNode.length > node.nextProducerIndex) { + node.producerNode.pop(); + node.producerLastReadVersion.pop(); + node.producerIndexOfThis.pop(); + } +} +function consumerPollProducersForChange(node) { + assertConsumerNode(node); + for (let i = 0; i < node.producerNode.length; i++) { + const producer = node.producerNode[i]; + const seenVersion = node.producerLastReadVersion[i]; + if (seenVersion !== producer.version) return true; + producerUpdateValueVersion(producer); + if (seenVersion !== producer.version) return true; + } + return false; +} +function producerAddLiveConsumer(node, consumer, indexOfThis) { + var _a; + assertProducerNode(node); + assertConsumerNode(node); + if (node.liveConsumerNode.length === 0) { + (_a = node.watched) == null || _a.call(node.wrapper); + for (let i = 0; i < node.producerNode.length; i++) + node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i); + } + node.liveConsumerIndexOfThis.push(indexOfThis); + return node.liveConsumerNode.push(consumer) - 1; +} +function producerRemoveLiveConsumerAtIndex(node, idx) { + var _a; + assertProducerNode(node); + assertConsumerNode(node); + if (typeof ngDevMode !== "undefined" && ngDevMode && idx >= node.liveConsumerNode.length) + throw new Error( + `Assertion error: active consumer index ${idx} is out of bounds of ${node.liveConsumerNode.length} consumers)`, + ); + if (node.liveConsumerNode.length === 1) { + (_a = node.unwatched) == null || _a.call(node.wrapper); + for (let i = 0; i < node.producerNode.length; i++) + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + } + const lastIdx = node.liveConsumerNode.length - 1; + node.liveConsumerNode[idx] = node.liveConsumerNode[lastIdx]; + node.liveConsumerIndexOfThis[idx] = node.liveConsumerIndexOfThis[lastIdx]; + node.liveConsumerNode.length--; + node.liveConsumerIndexOfThis.length--; + if (idx < node.liveConsumerNode.length) { + const idxProducer = node.liveConsumerIndexOfThis[idx]; + const consumer = node.liveConsumerNode[idx]; + assertConsumerNode(consumer); + consumer.producerIndexOfThis[idxProducer] = idx; + } +} +function consumerIsLive(node) { + var _a; + return ( + node.consumerIsAlwaysLive || + (((_a = node == null ? void 0 : node.liveConsumerNode) == null ? void 0 : _a.length) ?? 0) > 0 + ); +} +function assertConsumerNode(node) { + node.producerNode ?? (node.producerNode = []); + node.producerIndexOfThis ?? (node.producerIndexOfThis = []); + node.producerLastReadVersion ?? (node.producerLastReadVersion = []); +} +function assertProducerNode(node) { + node.liveConsumerNode ?? (node.liveConsumerNode = []); + node.liveConsumerIndexOfThis ?? (node.liveConsumerIndexOfThis = []); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function computedGet(node) { + producerUpdateValueVersion(node); + producerAccessed(node); + if (node.value === ERRORED) throw node.error; + return node.value; +} +function createComputed(computation) { + const node = Object.create(COMPUTED_NODE); + node.computation = computation; + const computed = () => computedGet(node); + computed[SIGNAL] = node; + return computed; +} +const UNSET = /* @__PURE__ */ Symbol("UNSET"); +const COMPUTING = /* @__PURE__ */ Symbol("COMPUTING"); +const ERRORED = /* @__PURE__ */ Symbol("ERRORED"); +const COMPUTED_NODE = { + ...REACTIVE_NODE, + value: UNSET, + dirty: true, + error: null, + equal: defaultEquals, + producerMustRecompute(node) { + return node.value === UNSET || node.value === COMPUTING; + }, + producerRecomputeValue(node) { + if (node.value === COMPUTING) throw new Error("Detected cycle in computations."); + const oldValue = node.value; + node.value = COMPUTING; + const prevConsumer = consumerBeforeComputation(node); + let newValue; + let wasEqual = false; + try { + newValue = node.computation.call(node.wrapper); + wasEqual = + oldValue !== UNSET && + oldValue !== ERRORED && + node.equal.call(node.wrapper, oldValue, newValue); + } catch (err) { + newValue = ERRORED; + node.error = err; + } finally { + consumerAfterComputation(node, prevConsumer); + } + if (wasEqual) { + node.value = oldValue; + return; + } + node.value = newValue; + node.version++; + }, +}; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function defaultThrowError() { + throw new Error(); +} +let throwInvalidWriteToSignalErrorFn = defaultThrowError; +function throwInvalidWriteToSignalError() { + throwInvalidWriteToSignalErrorFn(); +} +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +function createSignal(initialValue) { + const node = Object.create(SIGNAL_NODE); + node.value = initialValue; + const getter = () => { + producerAccessed(node); + return node.value; + }; + getter[SIGNAL] = node; + return getter; +} +function signalGetFn() { + producerAccessed(this); + return this.value; +} +function signalSetFn(node, newValue) { + if (!producerUpdatesAllowed()) throwInvalidWriteToSignalError(); + if (!node.equal.call(node.wrapper, node.value, newValue)) { + node.value = newValue; + signalValueChanged(node); + } +} +const SIGNAL_NODE = { + ...REACTIVE_NODE, + equal: defaultEquals, + value: void 0, +}; +function signalValueChanged(node) { + node.version++; + producerIncrementEpoch(); + producerNotifyConsumers(node); +} +/** + * @license + * Copyright 2024 Bloomberg Finance L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const NODE = Symbol("node"); +var Signal; +((Signal2) => { + var _a, _brand, _b, _brand2; + class State { + constructor(initialValue, options = {}) { + __privateAdd(this, _brand); + __publicField(this, _a); + const node = createSignal(initialValue)[SIGNAL]; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) node.equal = equals; + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isState)(this)) + throw new TypeError("Wrong receiver type for Signal.State.prototype.get"); + return signalGetFn.call(this[NODE]); + } + set(newValue) { + if (!(0, Signal2.isState)(this)) + throw new TypeError("Wrong receiver type for Signal.State.prototype.set"); + if (isInNotificationPhase()) + throw new Error("Writes to signals not permitted during Watcher callback"); + const ref = this[NODE]; + signalSetFn(ref, newValue); + } + } + _a = NODE; + _brand = /* @__PURE__ */ new WeakSet(); + Signal2.isState = (s) => typeof s === "object" && __privateIn(_brand, s); + Signal2.State = State; + class Computed { + constructor(computation, options) { + __privateAdd(this, _brand2); + __publicField(this, _b); + const node = createComputed(computation)[SIGNAL]; + node.consumerAllowSignalWrites = true; + this[NODE] = node; + node.wrapper = this; + if (options) { + const equals = options.equals; + if (equals) node.equal = equals; + node.watched = options[Signal2.subtle.watched]; + node.unwatched = options[Signal2.subtle.unwatched]; + } + } + get() { + if (!(0, Signal2.isComputed)(this)) + throw new TypeError("Wrong receiver type for Signal.Computed.prototype.get"); + return computedGet(this[NODE]); + } + } + _b = NODE; + _brand2 = /* @__PURE__ */ new WeakSet(); + Signal2.isComputed = (c) => typeof c === "object" && __privateIn(_brand2, c); + Signal2.Computed = Computed; + ((subtle2) => { + var _a2, _brand3, _assertSignals, assertSignals_fn; + function untrack(cb) { + let output; + let prevActiveConsumer = null; + try { + prevActiveConsumer = setActiveConsumer(null); + output = cb(); + } finally { + setActiveConsumer(prevActiveConsumer); + } + return output; + } + subtle2.untrack = untrack; + function introspectSources(sink) { + var _a3; + if (!(0, Signal2.isComputed)(sink) && !(0, Signal2.isWatcher)(sink)) + throw new TypeError("Called introspectSources without a Computed or Watcher argument"); + return ((_a3 = sink[NODE].producerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? []; + } + subtle2.introspectSources = introspectSources; + function introspectSinks(signal) { + var _a3; + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called introspectSinks without a Signal argument"); + return ( + ((_a3 = signal[NODE].liveConsumerNode) == null ? void 0 : _a3.map((n) => n.wrapper)) ?? [] + ); + } + subtle2.introspectSinks = introspectSinks; + function hasSinks(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called hasSinks without a Signal argument"); + const liveConsumerNode = signal[NODE].liveConsumerNode; + if (!liveConsumerNode) return false; + return liveConsumerNode.length > 0; + } + subtle2.hasSinks = hasSinks; + function hasSources(signal) { + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isWatcher)(signal)) + throw new TypeError("Called hasSources without a Computed or Watcher argument"); + const producerNode = signal[NODE].producerNode; + if (!producerNode) return false; + return producerNode.length > 0; + } + subtle2.hasSources = hasSources; + class Watcher { + constructor(notify) { + __privateAdd(this, _brand3); + __privateAdd(this, _assertSignals); + __publicField(this, _a2); + let node = Object.create(REACTIVE_NODE); + node.wrapper = this; + node.consumerMarkedDirty = notify; + node.consumerIsAlwaysLive = true; + node.consumerAllowSignalWrites = false; + node.producerNode = []; + this[NODE] = node; + } + watch(...signals) { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called unwatch without Watcher receiver"); + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + node.dirty = false; + const prev = setActiveConsumer(node); + for (const signal of signals) producerAccessed(signal[NODE]); + setActiveConsumer(prev); + } + unwatch(...signals) { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called unwatch without Watcher receiver"); + __privateMethod(this, _assertSignals, assertSignals_fn).call(this, signals); + const node = this[NODE]; + assertConsumerNode(node); + for (let i = node.producerNode.length - 1; i >= 0; i--) + if (signals.includes(node.producerNode[i].wrapper)) { + producerRemoveLiveConsumerAtIndex(node.producerNode[i], node.producerIndexOfThis[i]); + const lastIdx = node.producerNode.length - 1; + node.producerNode[i] = node.producerNode[lastIdx]; + node.producerIndexOfThis[i] = node.producerIndexOfThis[lastIdx]; + node.producerNode.length--; + node.producerIndexOfThis.length--; + node.nextProducerIndex--; + if (i < node.producerNode.length) { + const idxConsumer = node.producerIndexOfThis[i]; + const producer = node.producerNode[i]; + assertProducerNode(producer); + producer.liveConsumerIndexOfThis[idxConsumer] = i; + } + } + } + getPending() { + if (!(0, Signal2.isWatcher)(this)) + throw new TypeError("Called getPending without Watcher receiver"); + return this[NODE].producerNode.filter((n) => n.dirty).map((n) => n.wrapper); + } + } + _a2 = NODE; + _brand3 = /* @__PURE__ */ new WeakSet(); + _assertSignals = /* @__PURE__ */ new WeakSet(); + assertSignals_fn = function (signals) { + for (const signal of signals) + if (!(0, Signal2.isComputed)(signal) && !(0, Signal2.isState)(signal)) + throw new TypeError("Called watch/unwatch without a Computed or State argument"); + }; + Signal2.isWatcher = (w) => __privateIn(_brand3, w); + subtle2.Watcher = Watcher; + function currentComputed() { + var _a3; + return (_a3 = getActiveConsumer()) == null ? void 0 : _a3.wrapper; + } + subtle2.currentComputed = currentComputed; + subtle2.watched = Symbol("watched"); + subtle2.unwatched = Symbol("unwatched"); + })(Signal2.subtle || (Signal2.subtle = {})); +})(Signal || (Signal = {})); +/** + * equality check here is always false so that we can dirty the storage + * via setting to _anything_ + * + * + * This is for a pattern where we don't *directly* use signals to back the values used in collections + * so that instanceof checks and getters and other native features "just work" without having + * to do nested proxying. + * + * (though, see deep.ts for nested / deep behavior) + */ +const createStorage = (initial = null) => new Signal.State(initial, { equals: () => false }); +const ARRAY_GETTER_METHODS = new Set([ + Symbol.iterator, + "concat", + "entries", + "every", + "filter", + "find", + "findIndex", + "flat", + "flatMap", + "forEach", + "includes", + "indexOf", + "join", + "keys", + "lastIndexOf", + "map", + "reduce", + "reduceRight", + "slice", + "some", + "values", +]); +const ARRAY_WRITE_THEN_READ_METHODS = new Set(["fill", "push", "unshift"]); +function convertToInt(prop) { + if (typeof prop === "symbol") return null; + const num = Number(prop); + if (isNaN(num)) return null; + return num % 1 === 0 ? num : null; +} +var SignalArray = class SignalArray { + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + */ + /** + * Creates an array from an iterable object. + * @param iterable An iterable object to convert to an array. + * @param mapfn A mapping function to call on every element of the array. + * @param thisArg Value of 'this' used to invoke the mapfn. + */ + static from(iterable, mapfn, thisArg) { + return mapfn + ? new SignalArray(Array.from(iterable, mapfn, thisArg)) + : new SignalArray(Array.from(iterable)); + } + static of(...arr) { + return new SignalArray(arr); + } + constructor(arr = []) { + let clone = arr.slice(); + let self = this; + let boundFns = /* @__PURE__ */ new Map(); + /** + Flag to track whether we have *just* intercepted a call to `.push()` or + `.unshift()`, since in those cases (and only those cases!) the `Array` + itself checks `.length` to return from the function call. + */ + let nativelyAccessingLengthFromPushOrUnshift = false; + return new Proxy(clone, { + get(target, prop) { + let index = convertToInt(prop); + if (index !== null) { + self.#readStorageFor(index); + self.#collection.get(); + return target[index]; + } + if (prop === "length") { + if (nativelyAccessingLengthFromPushOrUnshift) + nativelyAccessingLengthFromPushOrUnshift = false; + else self.#collection.get(); + return target[prop]; + } + if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) + nativelyAccessingLengthFromPushOrUnshift = true; + if (ARRAY_GETTER_METHODS.has(prop)) { + let fn = boundFns.get(prop); + if (fn === void 0) { + fn = (...args) => { + self.#collection.get(); + return target[prop](...args); + }; + boundFns.set(prop, fn); + } + return fn; + } + return target[prop]; + }, + set(target, prop, value) { + target[prop] = value; + let index = convertToInt(prop); + if (index !== null) { + self.#dirtyStorageFor(index); + self.#collection.set(null); + } else if (prop === "length") self.#collection.set(null); + return true; + }, + getPrototypeOf() { + return SignalArray.prototype; + }, + }); + } + #collection = createStorage(); + #storages = /* @__PURE__ */ new Map(); + #readStorageFor(index) { + let storage = this.#storages.get(index); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(index, storage); + } + storage.get(); + } + #dirtyStorageFor(index) { + const storage = this.#storages.get(index); + if (storage) storage.set(null); + } +}; +Object.setPrototypeOf(SignalArray.prototype, Array.prototype); +var SignalMap = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + readStorageFor(key) { + const { storages } = this; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + storage.get(); + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = existing ? new Map(existing) : /* @__PURE__ */ new Map(); + } + get(key) { + this.readStorageFor(key); + return this.vals.get(key); + } + has(key) { + this.readStorageFor(key); + return this.vals.has(key); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + set(key, value) { + this.dirtyStorageFor(key); + this.collection.set(null); + this.vals.set(key, value); + return this; + } + delete(key) { + this.dirtyStorageFor(key); + this.collection.set(null); + return this.vals.delete(key); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalMap.prototype, Map.prototype); +/** + * Create a reactive Object, backed by Signals, using a Proxy. + * This allows dynamic creation and deletion of signals using the object primitive + * APIs that most folks are familiar with -- the only difference is instantiation. + * ```js + * const obj = new SignalObject({ foo: 123 }); + * + * obj.foo // 123 + * obj.foo = 456 + * obj.foo // 456 + * obj.bar = 2 + * obj.bar // 2 + * ``` + */ +const SignalObject = class SignalObjectImpl { + static fromEntries(entries) { + return new SignalObjectImpl(Object.fromEntries(entries)); + } + #storages = /* @__PURE__ */ new Map(); + #collection = createStorage(); + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj); + let clone = Object.create(proto); + for (let prop in descs) Object.defineProperty(clone, prop, descs[prop]); + let self = this; + return new Proxy(clone, { + get(target, prop, receiver) { + self.#readStorageFor(prop); + return Reflect.get(target, prop, receiver); + }, + has(target, prop) { + self.#readStorageFor(prop); + return prop in target; + }, + ownKeys(target) { + self.#collection.get(); + return Reflect.ownKeys(target); + }, + set(target, prop, value, receiver) { + let result = Reflect.set(target, prop, value, receiver); + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + return result; + }, + deleteProperty(target, prop) { + if (prop in target) { + delete target[prop]; + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + } + return true; + }, + getPrototypeOf() { + return SignalObjectImpl.prototype; + }, + }); + } + #readStorageFor(key) { + let storage = this.#storages.get(key); + if (storage === void 0) { + storage = createStorage(); + this.#storages.set(key, storage); + } + storage.get(); + } + #dirtyStorageFor(key) { + const storage = this.#storages.get(key); + if (storage) storage.set(null); + } + #dirtyCollection() { + this.#collection.set(null); + } +}; +var SignalSet = class { + collection = createStorage(); + storages = /* @__PURE__ */ new Map(); + vals; + storageFor(key) { + const storages = this.storages; + let storage = storages.get(key); + if (storage === void 0) { + storage = createStorage(); + storages.set(key, storage); + } + return storage; + } + dirtyStorageFor(key) { + const storage = this.storages.get(key); + if (storage) storage.set(null); + } + constructor(existing) { + this.vals = new Set(existing); + } + has(value) { + this.storageFor(value).get(); + return this.vals.has(value); + } + entries() { + this.collection.get(); + return this.vals.entries(); + } + keys() { + this.collection.get(); + return this.vals.keys(); + } + values() { + this.collection.get(); + return this.vals.values(); + } + forEach(fn) { + this.collection.get(); + this.vals.forEach(fn); + } + get size() { + this.collection.get(); + return this.vals.size; + } + [Symbol.iterator]() { + this.collection.get(); + return this.vals[Symbol.iterator](); + } + get [Symbol.toStringTag]() { + return this.vals[Symbol.toStringTag]; + } + add(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + this.vals.add(value); + return this; + } + delete(value) { + this.dirtyStorageFor(value); + this.collection.set(null); + return this.vals.delete(value); + } + clear() { + this.storages.forEach((s) => s.set(null)); + this.collection.set(null); + this.vals.clear(); + } +}; +Object.setPrototypeOf(SignalSet.prototype, Set.prototype); +function create() { + return new A2uiMessageProcessor({ + arrayCtor: SignalArray, + mapCtor: SignalMap, + objCtor: SignalObject, + setCtor: SignalSet, + }); +} +const Data = { + createSignalA2uiMessageProcessor: create, + A2uiMessageProcessor, + Guards: guards_exports, +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t$1 = (t) => (e, o) => { + void 0 !== o + ? o.addInitializer(() => { + customElements.define(t, e); + }) + : customElements.define(t, e); +}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const o$9 = { + attribute: !0, + type: String, + converter: u$3, + reflect: !1, + hasChanged: f$3, + }, + r$7 = (t = o$9, e, r) => { + const { kind: n, metadata: i } = r; + let s = globalThis.litPropertyMetadata.get(i); + if ( + (void 0 === s && globalThis.litPropertyMetadata.set(i, (s = /* @__PURE__ */ new Map())), + "setter" === n && ((t = Object.create(t)).wrapped = !0), + s.set(r.name, t), + "accessor" === n) + ) { + const { name: o } = r; + return { + set(r) { + const n = e.get.call(this); + (e.set.call(this, r), this.requestUpdate(o, n, t, !0, r)); + }, + init(e) { + return (void 0 !== e && this.C(o, void 0, t, e), e); + }, + }; + } + if ("setter" === n) { + const { name: o } = r; + return function (r) { + const n = this[o]; + (e.call(this, r), this.requestUpdate(o, n, t, !0, r)); + }; + } + throw Error("Unsupported decorator location: " + n); + }; +function n$6(t) { + return (e, o) => + "object" == typeof o + ? r$7(t, e, o) + : ((t, e, o) => { + const r = e.hasOwnProperty(o); + return ( + e.constructor.createProperty(o, t), r ? Object.getOwnPropertyDescriptor(e, o) : void 0 + ); + })(t, e, o); +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function r$6(r) { + return n$6({ + ...r, + state: !0, + attribute: !1, + }); +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const e$6 = (e, t, c) => ( + (c.configurable = !0), + (c.enumerable = !0), + Reflect.decorate && "object" != typeof t && Object.defineProperty(e, t, c), + c +); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ function e$5(e, r) { + return (n, s, i) => { + const o = (t) => t.renderRoot?.querySelector(e) ?? null; + if (r) { + const { get: e, set: r } = + "object" == typeof s + ? n + : (i ?? + (() => { + const t = Symbol(); + return { + get() { + return this[t]; + }, + set(e) { + this[t] = e; + }, + }; + })()); + return e$6(n, s, { + get() { + let t = e.call(this); + return ( + void 0 === t && ((t = o(this)), (null !== t || this.hasUpdated) && r.call(this, t)), t + ); + }, + }); + } + return e$6(n, s, { + get() { + return o(this); + }, + }); + }; +} +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ let i$2 = !1; +const s$1 = new Signal.subtle.Watcher(() => { + i$2 || + ((i$2 = !0), + queueMicrotask(() => { + i$2 = !1; + for (const t of s$1.getPending()) t.get(); + s$1.watch(); + })); + }), + h$3 = Symbol("SignalWatcherBrand"), + e$3 = new FinalizationRegistry((i) => { + i.unwatch(...Signal.subtle.introspectSources(i)); + }), + n$4 = /* @__PURE__ */ new WeakMap(); +function o$7(i) { + return !0 === i[h$3] + ? (console.warn("SignalWatcher should not be applied to the same class more than once."), i) + : class extends i { + constructor() { + (super(...arguments), + (this._$St = /* @__PURE__ */ new Map()), + (this._$So = new Signal.State(0)), + (this._$Si = !1)); + } + _$Sl() { + var t, i; + const s = [], + h = []; + this._$St.forEach((t, i) => { + ((null == t ? void 0 : t.beforeUpdate) ? s : h).push(i); + }); + const e = + null === (t = this.h) || void 0 === t + ? void 0 + : t.getPending().filter((t) => t !== this._$Su && !this._$St.has(t)); + (s.forEach((t) => t.get()), + null === (i = this._$Su) || void 0 === i || i.get(), + e.forEach((t) => t.get()), + h.forEach((t) => t.get())); + } + _$Sv() { + this.isUpdatePending || + queueMicrotask(() => { + this.isUpdatePending || this._$Sl(); + }); + } + _$S_() { + if (void 0 !== this.h) return; + this._$Su = new Signal.Computed(() => { + (this._$So.get(), super.performUpdate()); + }); + const i = (this.h = new Signal.subtle.Watcher(function () { + const t = n$4.get(this); + void 0 !== t && + (!1 === t._$Si && + (new Set(this.getPending()).has(t._$Su) ? t.requestUpdate() : t._$Sv()), + this.watch()); + })); + (n$4.set(i, this), + e$3.register(this, i), + i.watch(this._$Su), + i.watch(...Array.from(this._$St).map(([t]) => t))); + } + _$Sp() { + if (void 0 === this.h) return; + let i = !1; + (this.h.unwatch( + ...Signal.subtle.introspectSources(this.h).filter((t) => { + var s; + const h = + !0 !== (null === (s = this._$St.get(t)) || void 0 === s ? void 0 : s.manualDispose); + return (h && this._$St.delete(t), i || (i = !h), h); + }), + ), + i || ((this._$Su = void 0), (this.h = void 0), this._$St.clear())); + } + updateEffect(i, s) { + var h; + this._$S_(); + const e = new Signal.Computed(() => { + i(); + }); + return ( + this.h.watch(e), + this._$St.set(e, s), + null !== (h = null == s ? void 0 : s.beforeUpdate) && void 0 !== h && h + ? Signal.subtle.untrack(() => e.get()) + : this.updateComplete.then(() => Signal.subtle.untrack(() => e.get())), + () => { + (this._$St.delete(e), this.h.unwatch(e), !1 === this.isConnected && this._$Sp()); + } + ); + } + performUpdate() { + this.isUpdatePending && + (this._$S_(), + (this._$Si = !0), + this._$So.set(this._$So.get() + 1), + (this._$Si = !1), + this._$Sl()); + } + connectedCallback() { + (super.connectedCallback(), this.requestUpdate()); + } + disconnectedCallback() { + (super.disconnectedCallback(), + queueMicrotask(() => { + !1 === this.isConnected && this._$Sp(); + })); + } + }; +} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const s = (i, t) => { + const e = i._$AN; + if (void 0 === e) return !1; + for (const i of e) (i._$AO?.(t, !1), s(i, t)); + return !0; + }, + o$6 = (i) => { + let t, e; + do { + if (void 0 === (t = i._$AM)) break; + ((e = t._$AN), e.delete(i), (i = t)); + } while (0 === e?.size); + }, + r$3 = (i) => { + for (let t; (t = i._$AM); i = t) { + let e = t._$AN; + if (void 0 === e) t._$AN = e = /* @__PURE__ */ new Set(); + else if (e.has(i)) break; + (e.add(i), c(t)); + } + }; +function h$2(i) { + void 0 !== this._$AN ? (o$6(this), (this._$AM = i), r$3(this)) : (this._$AM = i); +} +function n$3(i, t = !1, e = 0) { + const r = this._$AH, + h = this._$AN; + if (void 0 !== h && 0 !== h.size) + if (t) + if (Array.isArray(r)) for (let i = e; i < r.length; i++) (s(r[i], !1), o$6(r[i])); + else null != r && (s(r, !1), o$6(r)); + else s(this, i); +} +const c = (i) => { + i.type == t$4.CHILD && ((i._$AP ??= n$3), (i._$AQ ??= h$2)); +}; +var f = class extends i$5 { + constructor() { + (super(...arguments), (this._$AN = void 0)); + } + _$AT(i, t, e) { + (super._$AT(i, t, e), r$3(this), (this.isConnected = i._$AU)); + } + _$AO(i, t = !0) { + (i !== this.isConnected && + ((this.isConnected = i), i ? this.reconnected?.() : this.disconnected?.()), + t && (s(this, i), o$6(this))); + } + setValue(t) { + if (r$8(this._$Ct)) this._$Ct._$AI(t, this); + else { + const i = [...this._$Ct._$AH]; + ((i[this._$Ci] = t), this._$Ct._$AI(i, this, 0)); + } + } + disconnected() {} + reconnected() {} +}; +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +let o$5 = !1; +const n$2 = new Signal.subtle.Watcher(async () => { + o$5 || + ((o$5 = !0), + queueMicrotask(() => { + o$5 = !1; + for (const i of n$2.getPending()) i.get(); + n$2.watch(); + })); +}); +var r$2 = class extends f { + _$S_() { + var i, t; + void 0 === this._$Sm && + ((this._$Sj = new Signal.Computed(() => { + var i; + const t = null === (i = this._$SW) || void 0 === i ? void 0 : i.get(); + return (this.setValue(t), t); + })), + (this._$Sm = + null !== (t = null === (i = this._$Sk) || void 0 === i ? void 0 : i.h) && void 0 !== t + ? t + : n$2), + this._$Sm.watch(this._$Sj), + Signal.subtle.untrack(() => { + var i; + return null === (i = this._$Sj) || void 0 === i ? void 0 : i.get(); + })); + } + _$Sp() { + void 0 !== this._$Sm && (this._$Sm.unwatch(this._$SW), (this._$Sm = void 0)); + } + render(i) { + return Signal.subtle.untrack(() => i.get()); + } + update(i, [t]) { + var o, n; + return ( + (null !== (o = this._$Sk) && void 0 !== o) || + (this._$Sk = null === (n = i.options) || void 0 === n ? void 0 : n.host), + t !== this._$SW && void 0 !== this._$SW && this._$Sp(), + (this._$SW = t), + this._$S_(), + Signal.subtle.untrack(() => this._$SW.get()) + ); + } + disconnected() { + this._$Sp(); + } + reconnected() { + this._$S_(); + } +}; +const h$1 = e$10(r$2), + m = + (o) => + (t, ...m) => + o( + t, + ...m.map((o) => (o instanceof Signal.State || o instanceof Signal.Computed ? h$1(o) : o)), + ); +m(b); +m(w); +Signal.State; +Signal.Computed; +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function* o$3(o, f) { + if (void 0 !== o) { + let i = 0; + for (const t of o) yield f(t, i++); + } +} +let pending = false; +let watcher = new Signal.subtle.Watcher(() => { + if (!pending) { + pending = true; + queueMicrotask(() => { + pending = false; + flushPending(); + }); + } +}); +function flushPending() { + for (const signal of watcher.getPending()) signal.get(); + watcher.watch(); +} +/** + * ⚠️ WARNING: Nothing unwatches ⚠️ + * This will produce a memory leak. + */ +function effect(cb) { + let c = new Signal.Computed(() => cb()); + watcher.watch(c); + c.get(); + return () => { + watcher.unwatch(c); + }; +} +const themeContext = n$7("A2UITheme"); +const structuralStyles = r$11(structuralStyles$1); +var ComponentRegistry = class { + constructor() { + this.registry = /* @__PURE__ */ new Map(); + } + register(typeName, constructor, tagName) { + if (!/^[a-zA-Z0-9]+$/.test(typeName)) + throw new Error(`[Registry] Invalid typeName '${typeName}'. Must be alphanumeric.`); + this.registry.set(typeName, constructor); + const actualTagName = tagName || `a2ui-custom-${typeName.toLowerCase()}`; + const existingName = customElements.getName(constructor); + if (existingName) { + if (existingName !== actualTagName) + throw new Error( + `Component ${typeName} is already registered as ${existingName}, but requested as ${actualTagName}.`, + ); + return; + } + if (!customElements.get(actualTagName)) customElements.define(actualTagName, constructor); + } + get(typeName) { + return this.registry.get(typeName); + } +}; +const componentRegistry = new ComponentRegistry(); +var __runInitializers$19 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +var __esDecorate$19 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +let Root = (() => { + let _classDecorators = [t$1("a2ui-root")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = o$7(i$6); + let _instanceExtraInitializers = []; + let _surfaceId_decorators; + let _surfaceId_initializers = []; + let _surfaceId_extraInitializers = []; + let _component_decorators; + let _component_initializers = []; + let _component_extraInitializers = []; + let _theme_decorators; + let _theme_initializers = []; + let _theme_extraInitializers = []; + let _childComponents_decorators; + let _childComponents_initializers = []; + let _childComponents_extraInitializers = []; + let _processor_decorators; + let _processor_initializers = []; + let _processor_extraInitializers = []; + let _dataContextPath_decorators; + let _dataContextPath_initializers = []; + let _dataContextPath_extraInitializers = []; + let _enableCustomElements_decorators; + let _enableCustomElements_initializers = []; + let _enableCustomElements_extraInitializers = []; + let _set_weight_decorators; + var Root = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _surfaceId_decorators = [n$6()]; + _component_decorators = [n$6()]; + _theme_decorators = [c$1({ context: themeContext })]; + _childComponents_decorators = [n$6({ attribute: false })]; + _processor_decorators = [n$6({ attribute: false })]; + _dataContextPath_decorators = [n$6()]; + _enableCustomElements_decorators = [n$6()]; + _set_weight_decorators = [n$6()]; + __esDecorate$19( + this, + null, + _surfaceId_decorators, + { + kind: "accessor", + name: "surfaceId", + static: false, + private: false, + access: { + has: (obj) => "surfaceId" in obj, + get: (obj) => obj.surfaceId, + set: (obj, value) => { + obj.surfaceId = value; + }, + }, + metadata: _metadata, + }, + _surfaceId_initializers, + _surfaceId_extraInitializers, + ); + __esDecorate$19( + this, + null, + _component_decorators, + { + kind: "accessor", + name: "component", + static: false, + private: false, + access: { + has: (obj) => "component" in obj, + get: (obj) => obj.component, + set: (obj, value) => { + obj.component = value; + }, + }, + metadata: _metadata, + }, + _component_initializers, + _component_extraInitializers, + ); + __esDecorate$19( + this, + null, + _theme_decorators, + { + kind: "accessor", + name: "theme", + static: false, + private: false, + access: { + has: (obj) => "theme" in obj, + get: (obj) => obj.theme, + set: (obj, value) => { + obj.theme = value; + }, + }, + metadata: _metadata, + }, + _theme_initializers, + _theme_extraInitializers, + ); + __esDecorate$19( + this, + null, + _childComponents_decorators, + { + kind: "accessor", + name: "childComponents", + static: false, + private: false, + access: { + has: (obj) => "childComponents" in obj, + get: (obj) => obj.childComponents, + set: (obj, value) => { + obj.childComponents = value; + }, + }, + metadata: _metadata, + }, + _childComponents_initializers, + _childComponents_extraInitializers, + ); + __esDecorate$19( + this, + null, + _processor_decorators, + { + kind: "accessor", + name: "processor", + static: false, + private: false, + access: { + has: (obj) => "processor" in obj, + get: (obj) => obj.processor, + set: (obj, value) => { + obj.processor = value; + }, + }, + metadata: _metadata, + }, + _processor_initializers, + _processor_extraInitializers, + ); + __esDecorate$19( + this, + null, + _dataContextPath_decorators, + { + kind: "accessor", + name: "dataContextPath", + static: false, + private: false, + access: { + has: (obj) => "dataContextPath" in obj, + get: (obj) => obj.dataContextPath, + set: (obj, value) => { + obj.dataContextPath = value; + }, + }, + metadata: _metadata, + }, + _dataContextPath_initializers, + _dataContextPath_extraInitializers, + ); + __esDecorate$19( + this, + null, + _enableCustomElements_decorators, + { + kind: "accessor", + name: "enableCustomElements", + static: false, + private: false, + access: { + has: (obj) => "enableCustomElements" in obj, + get: (obj) => obj.enableCustomElements, + set: (obj, value) => { + obj.enableCustomElements = value; + }, + }, + metadata: _metadata, + }, + _enableCustomElements_initializers, + _enableCustomElements_extraInitializers, + ); + __esDecorate$19( + this, + null, + _set_weight_decorators, + { + kind: "setter", + name: "weight", + static: false, + private: false, + access: { + has: (obj) => "weight" in obj, + set: (obj, value) => { + obj.weight = value; + }, + }, + metadata: _metadata, + }, + null, + _instanceExtraInitializers, + ); + __esDecorate$19( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Root = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #surfaceId_accessor_storage = + (__runInitializers$19(this, _instanceExtraInitializers), + __runInitializers$19(this, _surfaceId_initializers, null)); + get surfaceId() { + return this.#surfaceId_accessor_storage; + } + set surfaceId(value) { + this.#surfaceId_accessor_storage = value; + } + #component_accessor_storage = + (__runInitializers$19(this, _surfaceId_extraInitializers), + __runInitializers$19(this, _component_initializers, null)); + get component() { + return this.#component_accessor_storage; + } + set component(value) { + this.#component_accessor_storage = value; + } + #theme_accessor_storage = + (__runInitializers$19(this, _component_extraInitializers), + __runInitializers$19(this, _theme_initializers, void 0)); + get theme() { + return this.#theme_accessor_storage; + } + set theme(value) { + this.#theme_accessor_storage = value; + } + #childComponents_accessor_storage = + (__runInitializers$19(this, _theme_extraInitializers), + __runInitializers$19(this, _childComponents_initializers, null)); + get childComponents() { + return this.#childComponents_accessor_storage; + } + set childComponents(value) { + this.#childComponents_accessor_storage = value; + } + #processor_accessor_storage = + (__runInitializers$19(this, _childComponents_extraInitializers), + __runInitializers$19(this, _processor_initializers, null)); + get processor() { + return this.#processor_accessor_storage; + } + set processor(value) { + this.#processor_accessor_storage = value; + } + #dataContextPath_accessor_storage = + (__runInitializers$19(this, _processor_extraInitializers), + __runInitializers$19(this, _dataContextPath_initializers, "")); + get dataContextPath() { + return this.#dataContextPath_accessor_storage; + } + set dataContextPath(value) { + this.#dataContextPath_accessor_storage = value; + } + #enableCustomElements_accessor_storage = + (__runInitializers$19(this, _dataContextPath_extraInitializers), + __runInitializers$19(this, _enableCustomElements_initializers, false)); + get enableCustomElements() { + return this.#enableCustomElements_accessor_storage; + } + set enableCustomElements(value) { + this.#enableCustomElements_accessor_storage = value; + } + set weight(weight) { + this.#weight = weight; + this.style.setProperty("--weight", `${weight}`); + } + get weight() { + return this.#weight; + } + #weight = (__runInitializers$19(this, _enableCustomElements_extraInitializers), 1); + static { + this.styles = [ + structuralStyles, + i$9` + :host { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 80%; + } + `, + ]; + } + /** + * Holds the cleanup function for our effect. + * We need this to stop the effect when the component is disconnected. + */ + #lightDomEffectDisposer = null; + willUpdate(changedProperties) { + if (changedProperties.has("childComponents")) { + if (this.#lightDomEffectDisposer) this.#lightDomEffectDisposer(); + this.#lightDomEffectDisposer = effect(() => { + const allChildren = this.childComponents ?? null; + D(this.renderComponentTree(allChildren), this, { host: this }); + }); + } + } + /** + * Clean up the effect when the component is removed from the DOM. + */ + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#lightDomEffectDisposer) this.#lightDomEffectDisposer(); + } + /** + * Turns the SignalMap into a renderable TemplateResult for Lit. + */ + renderComponentTree(components) { + if (!components) return A; + if (!Array.isArray(components)) return A; + return b` ${o$3(components, (component) => { + if (this.enableCustomElements) { + const elCtor = + componentRegistry.get(component.type) || customElements.get(component.type); + if (elCtor) { + const node = component; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) el.slot = node.slotName; + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) el[prop] = val; + return b`${el}`; + } + } + switch (component.type) { + case "List": { + const node = component; + const childComponents = node.properties.children; + return b``; + } + case "Card": { + const node = component; + let childComponents = node.properties.children; + if (!childComponents && node.properties.child) + childComponents = [node.properties.child]; + return b``; + } + case "Column": { + const node = component; + return b``; + } + case "Row": { + const node = component; + return b``; + } + case "Image": { + const node = component; + return b``; + } + case "Icon": { + const node = component; + return b``; + } + case "AudioPlayer": { + const node = component; + return b``; + } + case "Button": { + const node = component; + return b``; + } + case "Text": { + const node = component; + return b``; + } + case "CheckBox": { + const node = component; + return b``; + } + case "DateTimeInput": { + const node = component; + return b``; + } + case "Divider": { + const node = component; + return b``; + } + case "MultipleChoice": { + const node = component; + return b``; + } + case "Slider": { + const node = component; + return b``; + } + case "TextField": { + const node = component; + return b``; + } + case "Video": { + const node = component; + return b``; + } + case "Tabs": { + const node = component; + const titles = []; + const childComponents = []; + if (node.properties.tabItems) + for (const item of node.properties.tabItems) { + titles.push(item.title); + childComponents.push(item.child); + } + return b``; + } + case "Modal": { + const node = component; + const childComponents = [node.properties.entryPointChild, node.properties.contentChild]; + node.properties.entryPointChild.slotName = "entry"; + return b``; + } + default: + return this.renderCustomComponent(component); + } + })}`; + } + renderCustomComponent(component) { + if (!this.enableCustomElements) return; + const node = component; + const elCtor = componentRegistry.get(component.type) || customElements.get(component.type); + if (!elCtor) return b`Unknown element ${component.type}`; + const el = new elCtor(); + el.id = node.id; + if (node.slotName) el.slot = node.slotName; + el.component = node; + el.weight = node.weight ?? "initial"; + el.processor = this.processor; + el.surfaceId = this.surfaceId; + el.dataContextPath = node.dataContextPath ?? "/"; + for (const [prop, val] of Object.entries(component.properties)) el[prop] = val; + return b`${el}`; + } + render() { + return b``; + } + static { + __runInitializers$19(_classThis, _classExtraInitializers); + } + }; + return _classThis; +})(); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const e$2 = e$10( + class extends i$5 { + constructor(t) { + if ((super(t), t.type !== t$4.ATTRIBUTE || "class" !== t.name || t.strings?.length > 2)) + throw Error( + "`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.", + ); + } + render(t) { + return ( + " " + + Object.keys(t) + .filter((s) => t[s]) + .join(" ") + + " " + ); + } + update(s, [i]) { + if (void 0 === this.st) { + ((this.st = /* @__PURE__ */ new Set()), + void 0 !== s.strings && + (this.nt = new Set( + s.strings + .join(" ") + .split(/\s/) + .filter((t) => "" !== t), + ))); + for (const t in i) i[t] && !this.nt?.has(t) && this.st.add(t); + return this.render(i); + } + const r = s.element.classList; + for (const t of this.st) t in i || (r.remove(t), this.st.delete(t)); + for (const t in i) { + const s = !!i[t]; + s === this.st.has(t) || + this.nt?.has(t) || + (s ? (r.add(t), this.st.add(t)) : (r.remove(t), this.st.delete(t))); + } + return E; + } + }, +); +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ const n$1 = "important", + i = " !" + n$1, + o$2 = e$10( + class extends i$5 { + constructor(t) { + if ((super(t), t.type !== t$4.ATTRIBUTE || "style" !== t.name || t.strings?.length > 2)) + throw Error( + "The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.", + ); + } + render(t) { + return Object.keys(t).reduce((e, r) => { + const s = t[r]; + return null == s + ? e + : e + + `${(r = r.includes("-") ? r : r.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g, "-$&").toLowerCase())}:${s};`; + }, ""); + } + update(e, [r]) { + const { style: s } = e.element; + if (void 0 === this.ft) return ((this.ft = new Set(Object.keys(r))), this.render(r)); + for (const t of this.ft) + null == r[t] && + (this.ft.delete(t), t.includes("-") ? s.removeProperty(t) : (s[t] = null)); + for (const t in r) { + const e = r[t]; + if (null != e) { + this.ft.add(t); + const r = "string" == typeof e && e.endsWith(i); + t.includes("-") || r + ? s.setProperty(t, r ? e.slice(0, -11) : e, r ? n$1 : "") + : (s[t] = e); + } + } + return E; + } + }, + ); +var __esDecorate$18 = function ( + ctor, + descriptorIn, + decorators, + contextIn, + initializers, + extraInitializers, +) { + function accept(f) { + if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); + return f; + } + var kind = contextIn.kind, + key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value"; + var target = !descriptorIn && ctor ? (contextIn["static"] ? ctor : ctor.prototype) : null; + var descriptor = + descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {}); + var _, + done = false; + for (var i = decorators.length - 1; i >= 0; i--) { + var context = {}; + for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p]; + for (var p in contextIn.access) context.access[p] = contextIn.access[p]; + context.addInitializer = function (f) { + if (done) throw new TypeError("Cannot add initializers after decoration has completed"); + extraInitializers.push(accept(f || null)); + }; + var result = (0, decorators[i])( + kind === "accessor" + ? { + get: descriptor.get, + set: descriptor.set, + } + : descriptor[key], + context, + ); + if (kind === "accessor") { + if (result === void 0) continue; + if (result === null || typeof result !== "object") throw new TypeError("Object expected"); + if ((_ = accept(result.get))) descriptor.get = _; + if ((_ = accept(result.set))) descriptor.set = _; + if ((_ = accept(result.init))) initializers.unshift(_); + } else if ((_ = accept(result))) + if (kind === "field") initializers.unshift(_); + else descriptor[key] = _; + } + if (target) Object.defineProperty(target, contextIn.name, descriptor); + done = true; +}; +var __runInitializers$18 = function (thisArg, initializers, value) { + var useValue = arguments.length > 2; + for (var i = 0; i < initializers.length; i++) + value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg); + return useValue ? value : void 0; +}; +(() => { + let _classDecorators = [t$1("a2ui-audioplayer")]; + let _classDescriptor; + let _classExtraInitializers = []; + let _classThis; + let _classSuper = Root; + let _url_decorators; + let _url_initializers = []; + let _url_extraInitializers = []; + var Audio = class extends _classSuper { + static { + _classThis = this; + } + static { + const _metadata = + typeof Symbol === "function" && Symbol.metadata + ? Object.create(_classSuper[Symbol.metadata] ?? null) + : void 0; + _url_decorators = [n$6()]; + __esDecorate$18( + this, + null, + _url_decorators, + { + kind: "accessor", + name: "url", + static: false, + private: false, + access: { + has: (obj) => "url" in obj, + get: (obj) => obj.url, + set: (obj, value) => { + obj.url = value; + }, + }, + metadata: _metadata, + }, + _url_initializers, + _url_extraInitializers, + ); + __esDecorate$18( + null, + (_classDescriptor = { value: _classThis }), + _classDecorators, + { + kind: "class", + name: _classThis.name, + metadata: _metadata, + }, + null, + _classExtraInitializers, + ); + Audio = _classThis = _classDescriptor.value; + if (_metadata) + Object.defineProperty(_classThis, Symbol.metadata, { + enumerable: true, + configurable: true, + writable: true, + value: _metadata, + }); + } + #url_accessor_storage = __runInitializers$18(this, _url_initializers, null); + get url() { + return this.#url_accessor_storage; + } + set url(value) { + this.#url_accessor_storage = value; + } + static { + this.styles = [ + structuralStyles, + i$9` + * { + box-sizing: border-box; + } + + :host { + display: block; + flex: var(--weight); + min-height: 0; + overflow: auto; + } + + audio { + display: block; + width: 100%; + } + `, + ]; + } + #renderAudio() { + if (!this.url) return A; + if (this.url && typeof this.url === "object") { + if ("literalString" in this.url) return b`