diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index fc4e532d49..6bbeb616cb 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1547,6 +1547,10 @@ export const PromptInput: Component = (props) => {
(scrollRef = el)}>
{ editorRef = el props.ref?.(el) @@ -1726,6 +1730,10 @@ export const PromptInput: Component = (props) => { >
{ editorRef = el props.ref?.(el) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 0b6a9a9389..b6034ef86c 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -25,11 +25,15 @@ import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" import { StatusPopover, StatusPopoverV2 } from "../status-popover" +import { statusTriggerVisibility } from "../status-popover-model" // AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator // noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is // where a solver-health panel (server/Julia env/runs dir) belongs later. -const AMICODE_HIDE_STATUS_POPOVER = true +// Un-hidden 2026-07-20 (amicode#159 test drive): the 7/7 hide targeted the +// popover's then-only content (upstream MCP/LSP operator noise). It now hosts +// the amicode-first Vaults + Connections tabs — hiding it orphaned both. +const AMICODE_HIDE_STATUS_POPOVER = false import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" @@ -164,7 +168,14 @@ export function SessionHeader() { const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true)) const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true)) const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true)) - const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true)) + // AMICODE (#174 AC2): unlike its siblings above, showStatus no longer gates + // the whole trigger — the popover now hosts the global Connections + Vaults + // tabs, and the setting defaults OFF, which orphaned them in every session. + // The settings row sells "server status", so that is all it scopes now: the + // health dot. Policy + rationale live in status-popover-model.ts (tested). + const statusVis = createMemo(() => + statusTriggerVisibility({ desktopV2: isDesktopV2(), showStatus: settings.general.showStatus() }), + ) const [exists, setExists] = createStore>>({ finder: true, @@ -239,7 +250,7 @@ export function SessionHeader() { messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) const v2ActionsState = createMemo(() => ({ - statusVisible: status(), + statusDotVisible: statusVis().healthDot, statusLabel: language.t("status.popover.trigger"), reviewLabel: language.t("command.review.toggle"), reviewKeybind: command.keybind("review.toggle"), @@ -444,9 +455,9 @@ export function SessionHeader() {
- + - + @@ -525,7 +536,9 @@ export function SessionHeader() { } type SessionHeaderV2ActionsState = { - statusVisible: boolean + /** AMICODE (#174 AC2): the trigger itself always renders in a session; the + * show-status setting only drives this health-dot flag. */ + statusDotVisible: boolean statusLabel: string reviewLabel: string reviewKeybind: string @@ -536,9 +549,9 @@ type SessionHeaderV2ActionsState = { function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) { return (
- + - + diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 92367fbadb..d9af93f790 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -10,6 +10,7 @@ import { createEffect, createMemo, createResource, + createSignal, For, type JSXElement, onCleanup, @@ -31,9 +32,20 @@ import { parseVaultsResponse, type VaultsView, } from "@opencode-ai/ui/amicode-vaults-tab" +import { + AmicodeConnectionsTab, + applyConnectionOverlay, + parseConnectionActionResponse, + parseConnectionsResponse, + type ConnectionActionView, + type ConnectionOverlay, + type ConnectionsView, + type CredentialSubmitPayload, +} from "@opencode-ai/ui/amicode-connections-tab" import { usePrompt } from "@/context/prompt" import { startPrompt } from "@/utils/start-prompt" import { authTokenFromCredentials } from "@/utils/server" +import { GLOBAL_STATUS_DEFAULT_TAB } from "./status-popover-model" const pluginEmptyMessage = (value: string, file: string): JSXElement => { const parts = value.split(file) @@ -308,40 +320,17 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( const pluginCount = createMemo(() => plugins().length) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) - // amicode: Vaults tab — fetched per-active-server when the popover opens - // (source flips truthy on open / server switch → refetch; closed keeps the - // last value, stale-while-revalidate). + // amicode: Vaults + Connections wiring — shared with the home-chrome global + // popover (#174), see createAmicodeStatusTabs below. This session mount + // routes "Manage vaults" through the composer of the OPEN session. const prompt = usePrompt() - const [vaultsRaw, { refetch: refetchVaults }] = createResource( - () => (props.shown() && server.current ? ServerConnection.key(server.current) : undefined), - async () => { - const conn = server.current - if (!conn) return undefined - const headers: Record = {} - if (conn.http.password) - headers.Authorization = `Basic ${authTokenFromCredentials({ - username: conn.http.username, - password: conn.http.password, - })}` - const res = await fetch(new URL("/amicode/vaults", conn.http.url), { headers }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - return (await res.json()) as unknown + const amicodeTabs = createAmicodeStatusTabs({ + shown: props.shown, + onManageVaults: () => { + props.onClose?.() + startPrompt(prompt, AMICODE_MANAGE_VAULTS_PROMPT) }, - ) - const vaultsView = createMemo(() => { - if (vaultsRaw.error) return { ok: false, mounts: [], error: language.t("dialog.vaults.fetchFailed") } - const raw = vaultsRaw() - if (raw === undefined) return undefined - return parseVaultsResponse(raw) }) - const vaultsCount = () => { - const view = vaultsView() - return view?.ok ? view.mounts.length : 0 - } - const onManageVaults = () => { - props.onClose?.() - startPrompt(prompt, AMICODE_MANAGE_VAULTS_PROMPT) - } return (
@@ -372,10 +361,7 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: ( {pluginCount() > 0 ? `${pluginCount()} ` : ""} {language.t("status.popover.tab.plugins")} - - {vaultsCount() > 0 ? `${vaultsCount()} ` : ""} - {language.t("status.popover.tab.vaults")} - + {!settings.general.newLayoutDesigns() && ( @@ -555,21 +541,257 @@ export function StatusPopoverBody(props: { shown: Accessor; onClose?: (
- -
-
- - -
+ + +
+ ) +} + +// amicode (#174): Vaults + Connections wiring, extracted so the session-header +// popover and the home-chrome global popover are ONE wiring with two mounts. +// Deliberately depends only on app-root contexts (useServer/useLanguage) — the +// home route mounts neither the directory-scoped sync context nor the +// composer's PromptProvider, so the manage-vaults action is injected. +type AmicodeStatusTabsState = ReturnType + +function createAmicodeStatusTabs(opts: { shown: Accessor; onManageVaults: () => void }) { + const server = useServer() + const language = useLanguage() + + // amicode: Vaults tab — fetched per-active-server when the popover opens + // (source flips truthy on open / server switch → refetch; closed keeps the + // last value, stale-while-revalidate). + const [vaultsRaw, { refetch: refetchVaults }] = createResource( + () => (opts.shown() && server.current ? ServerConnection.key(server.current) : undefined), + async () => { + const conn = server.current + if (!conn) return undefined + const res = await fetch(new URL("/amicode/vaults", conn.http.url), { headers: amicodeHeaders(conn) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as unknown + }, + ) + const vaultsView = createMemo(() => { + if (vaultsRaw.error) return { ok: false, mounts: [], error: language.t("dialog.vaults.fetchFailed") } + const raw = vaultsRaw() + if (raw === undefined) return undefined + return parseVaultsResponse(raw) + }) + const vaultsCount = () => { + const view = vaultsView() + return view?.ok ? view.mounts.length : 0 + } + + // amicode: Connections tab (#166) — same per-active-server fetch idiom as + // vaults above. Mutations are ONE round trip (#165 contract): the overlay + // renders "validating" while the POST runs, then the terminal connection + // from the SAME response replaces it. No polling loop. + const [connectionsRaw, { refetch: refetchConnections }] = createResource( + () => (opts.shown() && server.current ? ServerConnection.key(server.current) : undefined), + async () => { + const conn = server.current + if (!conn) return undefined + const res = await fetch(new URL("/amicode/connections", conn.http.url), { headers: amicodeHeaders(conn) }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return (await res.json()) as unknown + }, + ) + const [connectionsOverlay, setConnectionsOverlay] = createSignal({}) + const [connectionsActionError, setConnectionsActionError] = createSignal() + createEffect(() => { + connectionsRaw.state // a fresh GET supersedes any leftover action overlay + setConnectionsOverlay({}) + setConnectionsActionError(undefined) + }) + const connectionsView = createMemo(() => { + const base = (() => { + if (connectionsRaw.error) + return { ok: false, connections: [], error: language.t("dialog.connections.fetchFailed") } as ConnectionsView + const raw = connectionsRaw() + if (raw === undefined) return undefined + return parseConnectionsResponse(raw) + })() + return applyConnectionOverlay(base, connectionsOverlay()) + }) + const connectionsCount = () => { + const view = connectionsView() + return view?.ok ? view.connections.filter((conn) => conn.state === "connected").length : 0 + } + const runConnectionAction = async (id: string, path: string, body: unknown): Promise => { + setConnectionsActionError(undefined) + setConnectionsOverlay({ validating: id }) + const result = await (async (): Promise => { + const conn = server.current + if (!conn) return { ok: false, error: language.t("dialog.connections.fetchFailed") } + try { + const res = await fetch(new URL(path, conn.http.url), { + method: "POST", + headers: { ...amicodeHeaders(conn), "content-type": "application/json" }, + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return parseConnectionActionResponse((await res.json()) as unknown) + } catch { + return { ok: false, error: language.t("dialog.connections.fetchFailed") } + } + })() + if (result.ok && result.connection) { + setConnectionsOverlay({ terminal: result.connection }) + } else { + setConnectionsOverlay({}) + setConnectionsActionError(result.error ?? language.t("dialog.connections.fetchFailed")) + } + return result + } + const onSubmitCredential = (payload: CredentialSubmitPayload) => + runConnectionAction(payload.id, "/amicode/connections/credential", payload) + const onDisconnectConnection = (id: string) => void runConnectionAction(id, "/amicode/connections/disconnect", { id }) + const onRevalidateConnection = (id: string) => void runConnectionAction(id, "/amicode/connections/revalidate", { id }) + const connectionsLabels = createMemo(() => ({ + empty: language.t("dialog.connections.empty"), + retry: language.t("dialog.connections.retry"), + states: { + connected: language.t("dialog.connections.state.connected"), + "needs-key": language.t("dialog.connections.state.needsKey"), + invalid: language.t("dialog.connections.state.invalid"), + expired: language.t("dialog.connections.state.expired"), + unreachable: language.t("dialog.connections.state.unreachable"), + unentitled: language.t("dialog.connections.state.unentitled"), + validating: language.t("dialog.connections.state.validating"), + unknown: language.t("dialog.connections.state.unknown"), + }, + baseUrlPlaceholder: language.t("dialog.connections.baseUrlPlaceholder"), + tokenPlaceholder: language.t("dialog.connections.tokenPlaceholder"), + usernamePlaceholder: language.t("dialog.connections.usernamePlaceholder"), + passwordPlaceholder: language.t("dialog.connections.passwordPlaceholder"), + projectIdPlaceholder: language.t("dialog.connections.projectIdPlaceholder"), + submit: language.t("dialog.connections.submit"), + disconnect: language.t("dialog.connections.disconnect"), + revalidate: language.t("dialog.connections.revalidate"), + staleHint: language.t("dialog.connections.stale"), + sessionOnlyHint: language.t("dialog.connections.sessionOnly"), + // raw {{slot}} templates — the ui card fills them with render-time values + offlineHint: language.t("dialog.connections.offline"), + driftHint: language.t("dialog.connections.drift"), + })) + + return { + vaultsView, + vaultsCount, + refetchVaults, + onManageVaults: opts.onManageVaults, + connectionsView, + connectionsCount, + connectionsLabels, + connectionsActionError, + refetchConnections, + onSubmitCredential, + onDisconnectConnection, + onRevalidateConnection, + } +} + +const amicodeHeaders = (conn: ServerConnection.Any) => { + const headers: Record = {} + if (conn.http.password) + headers.Authorization = `Basic ${authTokenFromCredentials({ + username: conn.http.username, + password: conn.http.password, + })}` + return headers +} + +// The two shared tab triggers/contents — rendered inside each mount's own +// so Kobalte's tabs context resolves normally. +function AmicodeStatusTabTriggers(props: { state: AmicodeStatusTabsState }) { + const language = useLanguage() + return ( + <> + + {props.state.vaultsCount() > 0 ? `${props.state.vaultsCount()} ` : ""} + {language.t("status.popover.tab.vaults")} + + + {props.state.connectionsCount() > 0 ? `${props.state.connectionsCount()} ` : ""} + {language.t("status.popover.tab.connections")} + + + ) +} + +function AmicodeStatusTabContents(props: { state: AmicodeStatusTabsState }) { + const language = useLanguage() + return ( + <> + +
+
+ +
- +
+
+ + +
+
+ +
+
+
+ + ) +} + +// amicode (#174): the GLOBAL status surface — mounted from the home chrome's +// Connections entry, where no session (and no directory-scoped sync context) +// exists. Hosts only the global tabs (vaults + connections; mcp/lsp/plugins +// are per-directory) and pre-selects Connections, since that is the entry's +// label. Manage-vaults is injected: home starts a fresh draft session with +// the manage prompt instead of writing into an open composer. +export function StatusPopoverGlobalBody(props: { + shown: Accessor + onClose?: () => void + onManageVaults: () => void +}) { + const language = useLanguage() + const amicodeTabs = createAmicodeStatusTabs({ + shown: props.shown, + onManageVaults: () => { + props.onClose?.() + props.onManageVaults() + }, + }) + + return ( +
+ + + + +
) diff --git a/packages/app/src/components/status-popover-model.test.ts b/packages/app/src/components/status-popover-model.test.ts new file mode 100644 index 0000000000..00bafe04ab --- /dev/null +++ b/packages/app/src/components/status-popover-model.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { GLOBAL_STATUS_DEFAULT_TAB, GLOBAL_STATUS_TABS, statusTriggerVisibility } from "./status-popover-model" + +describe("statusTriggerVisibility", () => { + // amicode#174 AC2: the status trigger is the only per-session entry to the + // global Connections surface — it must render whenever a session is open, + // independent of the "Server status" desktop setting. + test("trigger always renders, regardless of the show-status setting", () => { + expect(statusTriggerVisibility({ desktopV2: true, showStatus: false }).trigger).toBe(true) + expect(statusTriggerVisibility({ desktopV2: true, showStatus: true }).trigger).toBe(true) + expect(statusTriggerVisibility({ desktopV2: false, showStatus: false }).trigger).toBe(true) + expect(statusTriggerVisibility({ desktopV2: false, showStatus: true }).trigger).toBe(true) + }) + + test("on desktop v2 the health dot follows the show-status setting", () => { + expect(statusTriggerVisibility({ desktopV2: true, showStatus: false }).healthDot).toBe(false) + expect(statusTriggerVisibility({ desktopV2: true, showStatus: true }).healthDot).toBe(true) + }) + + test("off desktop v2 the health dot always renders (setting is desktop-only)", () => { + expect(statusTriggerVisibility({ desktopV2: false, showStatus: false }).healthDot).toBe(true) + expect(statusTriggerVisibility({ desktopV2: false, showStatus: true }).healthDot).toBe(true) + }) +}) + +describe("global status surface (home chrome entry)", () => { + // amicode#174 AC1: the home-chrome entry opens the same Vaults + Connections + // surface the session popover hosts, with Connections pre-selected. The + // per-directory tabs (mcp/lsp/plugins) are NOT part of the global surface — + // home has no directory-scoped sync context. + test("global surface hosts exactly vaults + connections", () => { + expect([...GLOBAL_STATUS_TABS]).toEqual(["vaults", "connections"]) + }) + + test("connections is the pre-selected tab", () => { + expect(GLOBAL_STATUS_DEFAULT_TAB).toBe("connections") + expect(GLOBAL_STATUS_TABS).toContain(GLOBAL_STATUS_DEFAULT_TAB) + }) +}) diff --git a/packages/app/src/components/status-popover-model.ts b/packages/app/src/components/status-popover-model.ts new file mode 100644 index 0000000000..c274c7adf0 --- /dev/null +++ b/packages/app/src/components/status-popover-model.ts @@ -0,0 +1,38 @@ +// amicode (#174): pure decisions behind the status popover's two mounts — +// the session-header trigger and the home-chrome Connections entry. Kept +// JSX-free so the policy is unit-testable (the repo has no tsx harness). + +/** + * Session-header trigger policy (#174 AC2). + * + * The status popover hosts the global Connections + Vaults tabs, so the + * trigger is the only per-session entry to global credential config. It + * therefore renders whenever a session is open. The "Server status" desktop + * setting (settings.general.showStatus, default OFF) is scoped DOWN to the + * health-dot overlay only: its UI copy sells server health, not access to + * configuration, and hiding the whole button orphaned Connections/Vaults on + * every default-settings install. + */ +export function statusTriggerVisibility(input: { desktopV2: boolean; showStatus: boolean }): { + trigger: boolean + healthDot: boolean +} { + return { + trigger: true, + // The setting exists only on the desktop v2 chrome; elsewhere the dot + // keeps its historical always-on behavior. + healthDot: input.desktopV2 ? input.showStatus : true, + } +} + +/** + * Home-chrome (global) surface (#174 AC1): the same Vaults + Connections tabs + * the session popover hosts — and ONLY those. The mcp/lsp/plugins tabs are + * per-directory (they read the directory-scoped sync context, which the home + * route does not mount) and are meaningless before a session exists. + */ +export const GLOBAL_STATUS_TABS = ["vaults", "connections"] as const +export type GlobalStatusTab = (typeof GLOBAL_STATUS_TABS)[number] + +/** The home entry is labeled "Connections", so that tab opens pre-selected. */ +export const GLOBAL_STATUS_DEFAULT_TAB: GlobalStatusTab = "connections" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 7cb66773b6..5628f225a9 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -3,7 +3,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { Popover } from "@opencode-ai/ui/popover" -import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js" +import { Suspense, createMemo, createSignal, lazy, Show, type ComponentProps, type JSX } from "solid-js" import { useLanguage } from "@/context/language" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" @@ -11,8 +11,9 @@ import { useGlobal } from "@/context/global" const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody }))) const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody }))) +const GlobalBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverGlobalBody }))) -export function StatusPopover() { +export function StatusPopover(props: { healthDot?: boolean }) { const language = useLanguage() const server = useServer() const global = useGlobal() @@ -45,15 +46,19 @@ export function StatusPopover() {
-
+ {/* amicode (#174): the dot is the only part of the trigger the + show-status setting governs — see statusTriggerVisibility. */} + +
+
} class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" @@ -74,12 +79,12 @@ export function StatusPopover() { ) } -export function StatusPopoverV2(props: { scope?: "server" }) { +export function StatusPopoverV2(props: { scope?: "server"; healthDot?: boolean }) { if (props.scope === "server") return - return + return } -function DirectoryStatusPopover() { +function DirectoryStatusPopover(props: { healthDot?: boolean }) { const language = useLanguage() const server = useServer() const global = useGlobal() @@ -101,6 +106,7 @@ function DirectoryStatusPopover() { healthy: healthy(), serverHealth: serverHealth(), issue: mcpIssue(), + dotVisible: props.healthDot ?? true, label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -124,6 +130,7 @@ function ServerStatusPopover() { ready: serverHealth() !== undefined, healthy: serverHealth() === true, serverHealth: serverHealth(), + dotVisible: true, label: language.t("status.popover.trigger"), onOpenChange: setShown, body: () => ( @@ -142,6 +149,8 @@ type StatusPopoverState = { healthy: boolean serverHealth: boolean | undefined issue?: "critical" | "warning" + /** amicode (#174): the show-status setting scopes to the dot, not the trigger */ + dotVisible: boolean label: string onOpenChange: (value: boolean) => void body: () => JSX.Element @@ -193,10 +202,12 @@ function StatusPopoverView(props: { state: StatusPopoverState }) { trigger={
-
+ +
+
} {...popoverProps} @@ -205,3 +216,65 @@ function StatusPopoverView(props: { state: StatusPopoverState }) { ) } + +// amicode (#174): the home chrome's global Connections entry — a labeled pill +// matching the strip's Sessions control, opening the GLOBAL status surface +// (vaults + connections, Connections pre-selected). Home-safe by construction: +// nothing here touches the directory-scoped sync or prompt contexts, so it can +// mount before any session exists. One wiring, two mounts — the body is the +// same module the session-header popover lazy-loads. +export function GlobalConnectionsPopover(props: { onManageVaults: () => void }) { + const language = useLanguage() + const [shown, setShown] = createSignal(false) + + return ( + + } + trigger={ + <> + {language.t("home.connections.trigger")} + + + } + class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" + gutter={8} + placement="bottom-end" + > + + + } + > + setShown(false)} onManageVaults={props.onManageVaults} /> + + + + ) +} diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 2e86c02fd8..18bb7d824f 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" +import { installGlobalClipboardFallback } from "@/utils/global-clipboard" import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" @@ -119,39 +120,6 @@ const readClipboardText: Platform["readClipboardText"] = () => { }) } -// Amicode webview (generalized): the same sandboxed-iframe paste failure -// hits every OTHER editable in the app too — provider API-key fields, -// settings inputs, etc. — not just the chat composer (which has its own -// handler, above, and calls stopPropagation() on every paste it handles — -// so this document-level listener never double-fires there). It only -// activates when the native paste event gave nothing, so it's a no-op -// everywhere clipboardData already works (plain browser tabs, desktop). -const isFormField = (el: EventTarget | null): el is HTMLInputElement | HTMLTextAreaElement => - el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement - -const installGlobalPasteFallback = () => { - document.addEventListener("paste", (event) => { - const target = event.target - const isEditable = isFormField(target) || (target instanceof HTMLElement && target.isContentEditable) - if (!isEditable) return - if (event.clipboardData?.getData("text/plain")) return - - event.preventDefault() - void readClipboardText().then((text) => { - if (!text) return - if (isFormField(target)) { - const start = target.selectionStart ?? target.value.length - const end = target.selectionEnd ?? target.value.length - target.value = target.value.slice(0, start) + text + target.value.slice(end) - target.selectionStart = target.selectionEnd = start + text.length - target.dispatchEvent(new Event("input", { bubbles: true })) - } else { - document.execCommand("insertText", false, text) - } - }) - }) -} - // Amicode webview: the write side of readClipboardText. The sandboxed iframe's // native copy (and navigator.clipboard.writeText) never lands in the OS // clipboard, so ⌘V — which reads the OS clipboard over the bridge — would paste @@ -164,33 +132,6 @@ const writeClipboardText: Platform["writeClipboardText"] = (text) => { return Promise.resolve(true) } -// The copy-side companion to installGlobalPasteFallback: mirror every ⌘C/⌘X -// selection to the OS clipboard via the bridge so a following ⌘V pastes what -// was actually copied in-chat. Mirror-only (no preventDefault) — the native -// selection copy / cut-removal still runs; we just also update the OS clipboard -// the paste bridge reads. Reads the document selection, falling back to a -// form field's own selection (which window.getSelection() does not expose). -const installGlobalCopyBridge = () => { - if (window.parent === window) return - document.addEventListener( - "keydown", - (event) => { - if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return - const key = event.key.toLowerCase() - if (key !== "c" && key !== "x") return - let text = window.getSelection()?.toString() ?? "" - const target = event.target - if (!text && isFormField(target)) { - const start = target.selectionStart ?? 0 - const end = target.selectionEnd ?? 0 - text = target.value.slice(start, end) - } - if (text) void writeClipboardText(text) - }, - true, - ) -} - const root = document.getElementById("root") if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) @@ -253,8 +194,9 @@ if (import.meta.env.VITE_SENTRY_DSN) { } if (root instanceof HTMLElement) { - installGlobalPasteFallback() - installGlobalCopyBridge() + // Amicode webview: route ⌘V/⌘C/⌘X for every editable through the + // extension-host bridge (framed contexts only — self-gates unframed). + installGlobalClipboardFallback(window) const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) clearAuthToken() const server: ServerConnection.Http = { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e291090cac..8c5b231713 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -592,6 +592,7 @@ export const dict = { "home.sessions.group.today": "Today", "home.sessions.group.yesterday": "Yesterday", "home.sessions.group.older": "Older", + "home.connections.trigger": "Connections", "session.tab.session": "Session", "session.tab.review": "Review", @@ -686,6 +687,31 @@ export const dict = { "dialog.vaults.empty": "No vaults mounted", "dialog.vaults.retry": "Retry", "dialog.vaults.fetchFailed": "Could not reach the server for vault status", + "status.popover.tab.connections": "Connections", + "dialog.connections.empty": "No connections available", + "dialog.connections.retry": "Retry", + "dialog.connections.fetchFailed": "Could not reach the server for connection status", + "dialog.connections.state.connected": "Connected", + "dialog.connections.state.needsKey": "Not connected — enter a key to connect", + "dialog.connections.state.invalid": "Key rejected — check it and try again", + "dialog.connections.state.expired": "Token expired — reconnect to mint a fresh one", + "dialog.connections.state.unreachable": "Service unreachable — check the URL or try again", + "dialog.connections.state.unentitled": "Project not authorized — check the project ID", + "dialog.connections.state.validating": "Validating key…", + "dialog.connections.state.unknown": "Status needs attention", + "dialog.connections.baseUrlPlaceholder": "Service URL", + "dialog.connections.tokenPlaceholder": "API key", + "dialog.connections.usernamePlaceholder": "Username", + "dialog.connections.passwordPlaceholder": "Password", + "dialog.connections.projectIdPlaceholder": "Project ID", + "dialog.connections.submit": "Connect", + "dialog.connections.disconnect": "Disconnect", + "dialog.connections.revalidate": "Revalidate", + "dialog.connections.stale": "Last check is stale — revalidate to refresh", + "dialog.connections.sessionOnly": "Session-only — you'll be asked to reconnect after a restart", + "dialog.connections.offline": "Offline — last verified {{at}} as {{identity}}", + "dialog.connections.drift": + "This key answered as {{answered}}, was {{stored}} — historical runs may stop authorizing", "amicode.retry": "Retry", "amicode.unavailable": "status unavailable", "amicode.fetchFailed": "Could not reach the server for problem status", @@ -848,7 +874,11 @@ export const dict = { "settings.general.row.showTerminal.title": "Terminal", "settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar", "settings.general.row.showStatus.title": "Server status", - "settings.general.row.showStatus.description": "Show the server status button in the desktop title bar", + // amicode (#174): the button itself always shows while a session is open — + // it is the entry to global Connections/Vaults — so the setting scopes to + // the health indicator only. + "settings.general.row.showStatus.description": + "Show the server-health indicator on the status button in the desktop title bar", "settings.general.row.showCustomAgents.title": "Custom agents", "settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index c37f115e2e..5c0db20a99 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -62,7 +62,9 @@ import { type ServerHealth } from "@/utils/server-health" import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery" import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard" +import { AMICODE_MANAGE_VAULTS_PROMPT } from "@opencode-ai/ui/amicode-vaults-tab" import { AmicodeDefaultsCapsule } from "@/components/amicode-defaults-capsule" +import { GlobalConnectionsPopover } from "@/components/status-popover" import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card" import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards" import { @@ -600,7 +602,13 @@ function HomeDesign() { createEffect(() => { if (!menuOpen()) return const onDown = (e: MouseEvent) => { - if (chromeRoot && !chromeRoot.contains(e.target as Node)) setMenuOpen(false) + const target = e.target as Node + // Portaled popover content (the Connections entry, #174) counts as + // inside: closing the dropdown would display:none the popover's anchor + // mid-interaction. The in-flow panels (capsule, sessions) are already + // covered by the contains() check. + if (target instanceof Element && target.closest('[data-component="popover-content"]')) return + if (chromeRoot && !chromeRoot.contains(target)) setMenuOpen(false) } const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false) @@ -1001,6 +1009,13 @@ function HomeDesign() {
+ {/* amicode (#174 AC1): global Connections entry — Connections are + global credentials, so they must be reachable from a fresh + boot with no session open. Opens the same Vaults+Connections + surface the session-header status popover hosts (one wiring, + two mounts), with Connections pre-selected. Manage-vaults + hands off to a fresh draft session (home has no composer). */} + startWithPrompt(AMICODE_MANAGE_VAULTS_PROMPT)} /> void> = [] +afterEach(() => { + while (cleanups.length) cleanups.pop()!() + document.body.innerHTML = "" +}) + +// A framed window: the real happy-dom window's event plumbing (so DOM events +// dispatched on elements reach capture-phase listeners) with a foreign parent +// that records what the app posts — mirroring clipboard-bridge.test.ts. +function framedWindow() { + const posted: Array> = [] + const parent = { postMessage: (message: Record) => posted.push(message) } + const win = new Proxy(window, { + get(target, prop) { + if (prop === "parent") return parent + const value = Reflect.get(target, prop) + return typeof value === "function" ? (value as (...args: unknown[]) => unknown).bind(target) : value + }, + }) as unknown as Window + return { + win, + posted, + reply: (message: Record) => window.dispatchEvent(new MessageEvent("message", { data: message })), + } +} + +function install(win: Window) { + const uninstall = installGlobalClipboardFallback(win) + cleanups.push(uninstall) + return uninstall +} + +function field(type: string, value = "", start?: number, end?: number) { + const el = document.createElement("input") + el.type = type + el.value = value + document.body.appendChild(el) + if (start !== undefined) el.setSelectionRange(start, end ?? start) + return el +} + +function editableDiv(text: string) { + const el = document.createElement("div") + el.setAttribute("contenteditable", "true") + el.textContent = text + document.body.appendChild(el) + return el +} + +function selectWithin(el: HTMLElement, start: number, end: number) { + const range = document.createRange() + range.setStart(el.firstChild!, start) + range.setEnd(el.firstChild!, end) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + return selection +} + +// A stand-in for a framework-controlled input: records what a bubbling +// document-level "input" listener observes — the element's value must already +// hold the new text by the time the listener runs (how Solid syncs state). +function observeInput() { + const events: Array<{ value: string; inputType: string }> = [] + const onInput = (event: Event) => { + const target = event.target as HTMLElement + events.push({ + value: "value" in target ? (target as HTMLInputElement).value : (target.textContent ?? ""), + inputType: (event as InputEvent).inputType, + }) + } + document.addEventListener("input", onInput) + cleanups.push(() => document.removeEventListener("input", onInput)) + return events +} + +function keydown(el: Element, key: string, init: KeyboardEventInit = {}) { + const event = new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true, cancelable: true, ...init }) + el.dispatchEvent(event) + return event +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe("isEditableTarget", () => { + test("accepts text-like inputs (incl. password), textareas, and contenteditables", () => { + expect(isEditableTarget(field("text"))).toBe(true) + expect(isEditableTarget(field("password"))).toBe(true) + expect(isEditableTarget(field("search"))).toBe(true) + expect(isEditableTarget(field("email"))).toBe(true) + const textarea = document.createElement("textarea") + document.body.appendChild(textarea) + expect(isEditableTarget(textarea)).toBe(true) + expect(isEditableTarget(editableDiv("x"))).toBe(true) + }) + + test("rejects non-text controls, non-editables, and locked fields", () => { + expect(isEditableTarget(field("checkbox"))).toBe(false) + expect(isEditableTarget(field("file"))).toBe(false) + expect(isEditableTarget(document.createElement("button"))).toBe(false) + expect(isEditableTarget(document.createElement("div"))).toBe(false) + expect(isEditableTarget(null)).toBe(false) + const disabled = field("text") + disabled.disabled = true + expect(isEditableTarget(disabled)).toBe(false) + const readonly = field("password") + readonly.readOnly = true + expect(isEditableTarget(readonly)).toBe(false) + }) +}) + +describe("insertTextAtSelection", () => { + test("replaces a form field's selection and lands the caret after the insertion", () => { + const el = field("text", "abcdef", 2, 4) + const seen = observeInput() + + insertTextAtSelection(el, "XY") + + expect(el.value).toBe("abXYef") + expect(el.selectionStart).toBe(4) + expect(el.selectionEnd).toBe(4) + // The controlled-input contract: by the time the bubbling event arrives, + // reading target.value yields the new text. + expect(seen).toEqual([{ value: "abXYef", inputType: "insertFromPaste" }]) + }) + + test("inserts at a collapsed caret in a textarea", () => { + const el = document.createElement("textarea") + el.value = "solve a gate" + document.body.appendChild(el) + el.setSelectionRange(8, 8) + const seen = observeInput() + + insertTextAtSelection(el, "CZ") + + expect(el.value).toBe("solve a CZ gate") + expect(el.selectionStart).toBe(10) + expect(seen).toEqual([{ value: "solve a CZ gate", inputType: "insertFromPaste" }]) + }) + + test("contenteditable: replaces the DOM selection and dispatches insertFromPaste", () => { + const el = editableDiv("hello world") + selectWithin(el, 0, 5) + const seen = observeInput() + + insertTextAtSelection(el, "goodbye") + + expect(el.textContent).toBe("goodbye world") + expect(window.getSelection()?.toString()).toBe("") // collapsed after insert + expect(seen).toEqual([{ value: "goodbye world", inputType: "insertFromPaste" }]) + }) + + test("contenteditable: appends at the end when the selection lives elsewhere", () => { + const el = editableDiv("hqs-") + window.getSelection()?.removeAllRanges() + const seen = observeInput() + + insertTextAtSelection(el, "token") + + expect(el.textContent).toBe("hqs-token") + expect(seen).toEqual([{ value: "hqs-token", inputType: "insertFromPaste" }]) + }) +}) + +describe("extractSelection", () => { + test("returns a form field's selected slice without mutating it", () => { + const el = field("text", "solve a CZ gate", 0, 5) + const seen = observeInput() + + expect(extractSelection(el)).toBe("solve") + expect(el.value).toBe("solve a CZ gate") + expect(seen).toEqual([]) // copy is read-only: no input event + }) + + test("returns empty for a collapsed selection", () => { + expect(extractSelection(field("text", "abc", 1, 1))).toBe("") + }) + + test("cut removes the selection, collapses the caret, and dispatches deleteByCut", () => { + const el = field("password", "abcdef", 2, 4) + const seen = observeInput() + + expect(extractSelection(el, { cut: true })).toBe("cd") + expect(el.value).toBe("abef") + expect(el.selectionStart).toBe(2) + expect(el.selectionEnd).toBe(2) + expect(seen).toEqual([{ value: "abef", inputType: "deleteByCut" }]) + }) + + test("contenteditable: returns the selected text; cut removes it", () => { + const el = editableDiv("hello world") + selectWithin(el, 0, 6) + expect(extractSelection(el)).toBe("hello ") + expect(el.textContent).toBe("hello world") + + selectWithin(el, 0, 6) + const seen = observeInput() + expect(extractSelection(el, { cut: true })).toBe("hello ") + expect(el.textContent).toBe("world") + expect(seen).toEqual([{ value: "world", inputType: "deleteByCut" }]) + }) +}) + +describe("installGlobalClipboardFallback", () => { + test("mod+V in a framed credential field requests the OS clipboard and inserts the reply", async () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("password", "hqs-", 4, 4) + const seen = observeInput() + + const event = keydown(el, "v") + + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toHaveLength(1) + expect(bridge.posted[0]!.kind).toBe("clipboard-request") + + bridge.reply({ source: "amicode", kind: "clipboard", nonce: bridge.posted[0]!.nonce, text: "api-key-123" }) + await tick() + + expect(el.value).toBe("hqs-api-key-123") + expect(seen).toEqual([{ value: "hqs-api-key-123", inputType: "insertFromPaste" }]) + }) + + test("mod+V replaces the field's selection with the pasted text", async () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "wrong-token", 0, 11) + + keydown(el, "v") + bridge.reply({ source: "amicode", kind: "clipboard", nonce: bridge.posted[0]!.nonce, text: "right-token" }) + await tick() + + expect(el.value).toBe("right-token") + }) + + test("an empty bridge reply degrades to a no-op", async () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "untouched", 0, 0) + const seen = observeInput() + + keydown(el, "v") + bridge.reply({ source: "amicode", kind: "clipboard", nonce: bridge.posted[0]!.nonce, text: "" }) + await tick() + + expect(el.value).toBe("untouched") + expect(seen).toEqual([]) + }) + + test('targets inside [data-amc-clipboard="self"] keep their own paste handling', () => { + const bridge = framedWindow() + install(bridge.win) + const owner = document.createElement("div") + owner.setAttribute("data-amc-clipboard", "self") + document.body.appendChild(owner) + const el = document.createElement("input") + el.type = "text" + owner.appendChild(el) + + const event = keydown(el, "v") + + // The prompt input's own ⌘V handler runs instead — no double insertion. + expect(event.defaultPrevented).toBe(false) + expect(bridge.posted).toHaveLength(0) + }) + + test("mod+C pushes the field's selection to the OS clipboard via the bridge", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "solve a CZ gate", 0, 5) + + const event = keydown(el, "c") + + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([{ source: "amicode", kind: "clipboard-write", text: "solve" }]) + expect(el.value).toBe("solve a CZ gate") // copy never mutates + }) + + test("copy still mirrors inside self-marked subtrees — the marker only owns paste", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "draft prompt", 0, 5) + el.setAttribute("data-amc-clipboard", "self") + + keydown(el, "c") + + expect(bridge.posted).toEqual([{ source: "amicode", kind: "clipboard-write", text: "draft" }]) + }) + + test("mod+X (ctrl too) pushes the selection and removes it", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "abcdef", 2, 4) + const seen = observeInput() + + const event = keydown(el, "x", { metaKey: false, ctrlKey: true }) + + expect(event.defaultPrevented).toBe(true) + expect(bridge.posted).toEqual([{ source: "amicode", kind: "clipboard-write", text: "cd" }]) + expect(el.value).toBe("abef") + expect(seen).toEqual([{ value: "abef", inputType: "deleteByCut" }]) + }) + + test("mod+C with nothing selected posts nothing and leaves the event alone", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "abc", 1, 1) + + const event = keydown(el, "c") + + expect(event.defaultPrevented).toBe(false) + expect(bridge.posted).toHaveLength(0) + }) + + test("non-editable targets are never intercepted", () => { + const bridge = framedWindow() + install(bridge.win) + const button = document.createElement("button") + document.body.appendChild(button) + + for (const key of ["v", "c", "x"]) { + const event = keydown(button, key) + expect(event.defaultPrevented).toBe(false) + } + expect(bridge.posted).toHaveLength(0) + }) + + test("unframed windows are left entirely to native clipboard handling", async () => { + install(window) // happy-dom's top-level window: parent === self + const el = field("text", "native", 0, 6) + const seen = observeInput() + + const event = keydown(el, "v") + await tick() + + expect(event.defaultPrevented).toBe(false) + expect(el.value).toBe("native") + expect(seen).toEqual([]) + }) + + test("shift/alt chords and unmodified keys are ignored", () => { + const bridge = framedWindow() + install(bridge.win) + const el = field("text", "abcdef", 0, 3) + + expect(keydown(el, "v", { shiftKey: true }).defaultPrevented).toBe(false) + expect(keydown(el, "c", { altKey: true }).defaultPrevented).toBe(false) + expect(keydown(el, "v", { metaKey: false }).defaultPrevented).toBe(false) + expect(bridge.posted).toHaveLength(0) + }) + + test("the returned uninstall detaches the handler", () => { + const bridge = framedWindow() + const uninstall = install(bridge.win) + uninstall() + const el = field("text", "abc", 0, 3) + + const event = keydown(el, "c") + + expect(event.defaultPrevented).toBe(false) + expect(bridge.posted).toHaveLength(0) + }) +}) diff --git a/packages/app/src/utils/global-clipboard.ts b/packages/app/src/utils/global-clipboard.ts new file mode 100644 index 0000000000..cf49516abf --- /dev/null +++ b/packages/app/src/utils/global-clipboard.ts @@ -0,0 +1,170 @@ +// Framed-app clipboard fallback, generalized from the prompt input's bridge. +// Inside the VS Code webview iframe, native paste never fires and native +// copy never reaches the OS clipboard (see prompt-input/clipboard-bridge.ts +// for the full why) — so every editable outside the prompt (Connections +// credential fields, settings inputs, …) silently ignores ⌘V and poisons the +// next paste on ⌘C. This module intercepts mod+V/C/X at the window's capture +// phase and routes them over the existing extension-host bridge. Unframed +// (plain web/desktop), it does nothing — native clipboard behavior stands. + +import { readClipboardViaBridge, writeClipboardViaBridge } from "@/components/prompt-input/clipboard-bridge" + +// Elements that carry their own bridged paste (the prompt input's ⌘V handler, +// the profile fields' pasteFallback) mark themselves so the fallback doesn't +// double-insert. The marker owns PASTE only: nothing element-local handles +// copy/cut, so ⌘C/⌘X still mirror to the OS clipboard even inside marked +// subtrees — otherwise copying from the prompt would paste stale content. +export const CLIPBOARD_SELF_SELECTOR = '[data-amc-clipboard="self"]' + +type FormField = HTMLInputElement | HTMLTextAreaElement + +// Input types that hold free text and support the selection API. Everything +// else (checkbox, file, range, date, …) has no text caret to paste at. +const TEXT_INPUT_TYPES = new Set(["text", "search", "url", "tel", "password", "email"]) + +const isFormField = (el: unknown): el is FormField => + el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement + +export function isEditableTarget(el: EventTarget | null): el is HTMLElement { + if (el instanceof HTMLInputElement) return TEXT_INPUT_TYPES.has(el.type) && !el.disabled && !el.readOnly + if (el instanceof HTMLTextAreaElement) return !el.disabled && !el.readOnly + return el instanceof HTMLElement && el.isContentEditable +} + +// email (and, in some browsers, other text-like types) throws on selection +// access — degrade to append-at-end rather than losing the paste entirely. +function fieldSelection(el: FormField): { start: number; end: number } { + try { + const start = el.selectionStart + const end = el.selectionEnd + if (start !== null && end !== null) return { start, end } + } catch { + // no selection API for this input type + } + return { start: el.value.length, end: el.value.length } +} + +function setCaret(el: FormField, at: number) { + try { + el.setSelectionRange(at, at) + } catch { + // no selection API — the value update above still landed + } +} + +// Manual dispatch discipline: setRangeText and Range edits fire no events, but +// Solid-controlled inputs only sync state from a bubbling "input" event. +function dispatchInput(el: HTMLElement, inputType: string, data?: string) { + el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType, data })) +} + +export function insertTextAtSelection(el: HTMLElement, text: string): void { + if (isFormField(el)) { + const { start, end } = fieldSelection(el) + if (typeof el.setRangeText === "function") { + el.setRangeText(text, start, end) + } else { + el.value = el.value.slice(0, start) + text + el.value.slice(end) + } + // Place the caret ourselves: setRangeText's "end" selection mode is not + // reliable across DOM implementations (happy-dom lands it off-spec). + setCaret(el, start + text.length) + dispatchInput(el, "insertFromPaste", text) + return + } + + // contenteditable: prefer execCommand — the browser splices the text at the + // caret and fires the input event itself, exactly like a native paste. + const doc = el.ownerDocument + if (typeof doc.execCommand === "function") { + el.focus() + try { + if (doc.execCommand("insertText", false, text)) return + } catch { + // fall through to the manual range splice + } + } + const selection = doc.defaultView?.getSelection() + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null + const node = doc.createTextNode(text) + if (selection && range && el.contains(range.commonAncestorContainer)) { + range.deleteContents() + range.insertNode(node) + range.setStartAfter(node) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } else { + // No usable selection inside the element — append rather than drop the paste. + el.appendChild(node) + } + dispatchInput(el, "insertFromPaste", text) +} + +export function extractSelection(el: HTMLElement, opts: { cut?: boolean } = {}): string { + if (isFormField(el)) { + const { start, end } = fieldSelection(el) + const text = el.value.slice(start, end) + if (!text) return "" + if (opts.cut) { + if (typeof el.setRangeText === "function") { + el.setRangeText("", start, end) + } else { + el.value = el.value.slice(0, start) + el.value.slice(end) + } + setCaret(el, start) + dispatchInput(el, "deleteByCut") + } + return text + } + + const selection = el.ownerDocument.defaultView?.getSelection() + if (!selection || selection.rangeCount === 0) return "" + const range = selection.getRangeAt(0) + // Only speak for selections that actually live inside this editable — + // cutting must never delete content the keystroke's target doesn't own. + if (!el.contains(range.commonAncestorContainer)) return "" + const text = selection.toString() + if (!text) return "" + if (opts.cut) { + range.deleteContents() // leaves the selection collapsed at the cut point + dispatchInput(el, "deleteByCut") + } + return text +} + +// Capture-phase so it sees the keystroke before any component handler, and +// window-level so portaled UI (popovers, dialogs) is covered too. Returns an +// uninstall function; the handler re-checks framing per event, so installing +// unconditionally at startup is safe everywhere. +export function installGlobalClipboardFallback(win: Window = window): () => void { + const onKeyDown = (event: KeyboardEvent) => { + if (win.parent === win) return // unframed: native clipboard works — stay out + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return + if (event.isComposing) return + const key = event.key.toLowerCase() + if (key !== "v" && key !== "c" && key !== "x") return + const target = event.target + if (!isEditableTarget(target)) return // non-editables keep native behavior + + if (key === "v") { + if (target.closest(CLIPBOARD_SELF_SELECTOR)) return // element owns its own paste + // Native paste never fires in-frame, so preventDefault loses nothing; + // an empty or dead bridge reply degrades to a no-op (see clipboard-bridge). + event.preventDefault() + void readClipboardViaBridge(win).then((text) => { + if (!text) return + insertTextAtSelection(target, text) + }) + return + } + + const text = extractSelection(target, { cut: key === "x" }) + if (!text) return // nothing selected: the native no-op stands + event.preventDefault() + writeClipboardViaBridge(text, win) + } + + win.addEventListener("keydown", onKeyDown, true) + return () => win.removeEventListener("keydown", onKeyDown, true) +} diff --git a/packages/opencode/src/server/amicode/connections.ts b/packages/opencode/src/server/amicode/connections.ts new file mode 100644 index 0000000000..d0de2fa2d8 --- /dev/null +++ b/packages/opencode/src/server/amicode/connections.ts @@ -0,0 +1,1023 @@ +// AMICODE: Connections routes data source (amicode#165 / parent #159, ADR +// 0002) — Company Compute connect path. Probe-first validation: a submitted +// key is classified against the solve service's fake-task status route BEFORE +// anything touches disk; only auth-passed classes write through the #162 +// CredentialStore seam. SECURITY: secrets ride POST bodies and Authorization +// headers ONLY — never URLs, never query params, never error messages or +// logs. Every status response is built through a redacting whitelist parser, +// so no input (cache file, in-memory state) can leak a token into a body. +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import path from "node:path" +import { + atomicWriteFileSync, + clearCredential, + credentialFileMtime, + readCredential, + writeCredential, + type ConnectionType, + type PasqalCredential, +} from "./credentials" +import { parseTomlLite } from "./toml-lite" + +// --- status contract (parent #159 data contract; secret-free by construction) --- + +/** This slice only ever produces connected / needs-key / invalid / + * unreachable / validating for company-compute; expired + unentitled are + * forward-compatible states later slices fill in. */ +export type ConnectionState = + | "connected" + | "needs-key" + | "invalid" + | "expired" + | "unreachable" + | "unentitled" + | "validating" + +export interface ConnectionDevice { + id?: string + name?: string + state?: string +} + +export interface ConnectionStatus { + id: ConnectionType + state: ConnectionState + identity?: string + /** 170 AC4 (the 2026-07-19 incident canary): the submitter this credential + * NOW answers as, when a revalidation echo disagrees with the stored + * `identity`. The stored identity is the immutable record; this field is + * the diff — presence IS the drift signal. Reconciliation is a human act + * (re-submitting the credential resets the record). */ + identity_drift?: string + entitlements?: string[] + expires_at?: string + devices?: ConnectionDevice[] + validated_at: string | null + stale: boolean + /** 169 AC4: connected purely in-memory (Pasqal minted no persistable + * token) — the claim dies with the server process. */ + session_only?: boolean + /** 170 AC3: the last background revalidation could not REACH the service — + * a presentation flag on a connected claim ("last verified + * as "), never a verdict on the credential. Connected-only. */ + offline?: boolean +} + +/** The connection cards this module serves; company-compute renders first. */ +export const CONNECTION_IDS: ConnectionType[] = ["company-compute", "pasqal-cloud"] + +/** A connected claim older than this renders stale:true — the UI's cue to + * offer revalidation. Freshness metadata only; never blocks anything. */ +export const STALE_MS = 24 * 60 * 60 * 1000 + +/** Mtime staleness slack (170 AC1): a credential file counts as hand-edited + * only when its mtime lands more than this AFTER validated_at. The connect + * path writes the file within moments of stamping validated_at (either + * order), so the slack keeps a fresh connect from reading as an edit while + * any real out-of-band edit — minutes or hours later — still trips it. */ +export const MTIME_STALE_SLACK_MS = 5_000 + +/** Non-secret status cache — the ops-dir env-override idiom the siblings use + * (problems.ts / profile.ts / credentials.ts): $AMICODE_CONNECTIONS_FILE + * overrides, default lives beside cloud.json under ~/.amico. Holds ONLY + * whitelisted status fields; credentials live in the #162 store. */ +export function connectionsFile(): string { + const env = process.env.AMICODE_CONNECTIONS_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "connections.json") +} + +/** In-memory in-flight state: while a submit/revalidate probe runs, its id + * maps to an entry here and concurrent GETs render "validating". Exported as + * the in-memory seam the poison test seeds — whatever lands in an entry, only + * the whitelist below can reach a response. */ +export const inflightOverlay = new Map>() + +/** In-memory session-only claims (169 AC4): a Pasqal validation that minted + * NO persistable token parks its connected status — identity, devices, + * validated_at — here and ONLY here. Nothing reaches disk, so a fresh status + * build after a restart renders needs-key and the card re-prompts. Same + * redaction discipline as the in-flight overlay: entries pass the whitelist + * before any response. */ +export const sessionOnlyOverlay = new Map>() + +// --- the redacting whitelist parser: the ONLY way status inputs become a +// response. It builds a FRESH object from declared fields with type checks — +// unknown keys (token, password, anything) have no path into the output. + +const KNOWN_STATES: ReadonlySet = new Set([ + "connected", + "needs-key", + "invalid", + "expired", + "unreachable", + "unentitled", + "validating", +]) + +function str(v: unknown): string | undefined { + return typeof v === "string" && v !== "" ? v : undefined +} + +function isKnownState(v: string): v is ConnectionState { + return KNOWN_STATES.has(v) +} + +function whitelistDevices(v: unknown): ConnectionDevice[] | undefined { + if (!Array.isArray(v)) return undefined + const out: ConnectionDevice[] = [] + for (const raw of v) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue + const d = raw as Record + const device: ConnectionDevice = {} + const id = str(d.id) + const name = str(d.name) + const state = str(d.state) + if (id) device.id = id + if (name) device.name = name + if (state) device.state = state + if (Object.keys(device).length > 0) out.push(device) + } + return out.length > 0 ? out : undefined +} + +/** Whitelist one persisted cache entry: only known-safe fields survive, each + * type-checked and rebuilt. Everything else — poisoned or not — is dropped. */ +function whitelistPersisted(raw: unknown): Partial { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {} + const d = raw as Record + const out: Partial = {} + const state = str(d.state) + if (state && isKnownState(state)) out.state = state + const identity = str(d.identity) + if (identity) out.identity = identity + const drift = str(d.identity_drift) + if (drift) out.identity_drift = drift + if (Array.isArray(d.entitlements)) { + const entitlements = d.entitlements.filter((e): e is string => typeof e === "string" && e !== "") + if (entitlements.length > 0) out.entitlements = entitlements + } + const expires = str(d.expires_at) + if (expires) out.expires_at = expires + const devices = whitelistDevices(d.devices) + if (devices) out.devices = devices + const validated = str(d.validated_at) + if (validated) out.validated_at = validated + if (d.offline === true) out.offline = true // only the literal true — anything else is noise + return out +} + +/** 170 AC5: expires_at at or behind now. Absent/unparseable expiry never + * expires anything — the honest minimum. */ +function isPastExpiry(expires_at: string | undefined, now: number): boolean { + if (!expires_at) return false + const at = Date.parse(expires_at) + return Number.isFinite(at) && at <= now +} + +function computeStale(state: ConnectionState, validated_at: string | null, now: number, mtime?: number): boolean { + if (state !== "connected") return false + if (!validated_at) return true + const at = Date.parse(validated_at) + if (!Number.isFinite(at)) return true + if (now - at > STALE_MS) return true + // 170 AC1: a credential file hand-edited AFTER its last validation is a + // desync the 24h clock cannot see — the file itself marks the claim stale + return mtime !== undefined && mtime - at > MTIME_STALE_SLACK_MS +} + +/** Derive the rendered status for one connection from its whitelisted cache + * entry, the in-flight overlay, the session-only store, and credential + * presence (the truth for durable "connected"). Output carries ONLY + * whitelisted fields. */ +function renderStatus( + id: ConnectionType, + persisted: Partial, + input: { inflight: boolean; credential: boolean; now: number; mtime?: number; session?: Partial }, +): ConnectionStatus { + if (!input.inflight && input.session?.state === "connected") { + // session-only claim (169 AC4): connected without a credential at rest — + // rendered from memory alone, marked so the card can say so + const validated_at = input.session.validated_at ?? null + const out: ConnectionStatus = { + id, + state: "connected", + validated_at, + stale: computeStale("connected", validated_at, input.now), + session_only: true, + } + if (input.session.identity) out.identity = input.session.identity + if (input.session.entitlements) out.entitlements = input.session.entitlements + if (input.session.expires_at) out.expires_at = input.session.expires_at + if (input.session.devices) out.devices = input.session.devices + return out + } + let state: ConnectionState + if (input.inflight) state = "validating" + else if (persisted.state === "connected") state = input.credential ? "connected" : "needs-key" + else if (persisted.state) state = persisted.state + else state = input.credential ? "connected" : "needs-key" + + // 170 AC5: a past expiry outranks a connected claim AT READ TIME — the + // reconnect prompt renders without waiting for a revalidation to notice, + // identically for company-compute and pasqal. + if (state === "connected" && isPastExpiry(persisted.expires_at, input.now)) state = "expired" + + const validated_at = state === "needs-key" ? null : (persisted.validated_at ?? null) + const out: ConnectionStatus = { + id, + state, + validated_at, + stale: computeStale(state, validated_at, input.now, input.mtime), + } + if (state !== "needs-key") { + if (persisted.identity) out.identity = persisted.identity + if (persisted.identity_drift) out.identity_drift = persisted.identity_drift + if (persisted.entitlements) out.entitlements = persisted.entitlements + if (persisted.expires_at) out.expires_at = persisted.expires_at + if (persisted.devices) out.devices = persisted.devices + } + // 170 AC3: offline is a connected-only presentation flag — "showing the + // last verified status" makes no sense on any other state + if (state === "connected" && persisted.offline) out.offline = true + return out +} + +function readCacheFile(file: string): Record { + try { + if (!existsSync(file)) return {} + const raw: unknown = JSON.parse(readFileSync(file, "utf8")) + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {} + return raw as Record + } catch { + return {} // missing/unreadable/unparseable → empty, never a throw + } +} + +export function synthesizeConnections(code: string, detail: string): string { + return JSON.stringify({ ok: false, connections: [], error: `${code}: ${detail}` }) +} + +export interface StatusInput { + file: string + overlay: ReadonlyMap> + hasCredential: (id: ConnectionType) => boolean + /** credential-file mtime per id (ms) — the hand-edit detector (170 AC1). + * Absent seam (pure tests) or absent file → undefined, mtime rule off. */ + credentialMtime?: (id: ConnectionType) => number | undefined + /** in-memory session-only claims; ABSENT by default so a fresh build from + * disk (= a restarted server) cannot see them (169 AC4) */ + session?: ReadonlyMap> + now?: number +} + +/** Pure body-builder over injectable inputs (profile.ts idiom); the route + * entrypoint below binds the real file/overlay/credential store. */ +export function statusBody(input: StatusInput): string { + const cache = readCacheFile(input.file) + const now = input.now ?? Date.now() + const connections = CONNECTION_IDS.map((id) => { + const session = input.session?.get(id) + const mtime = input.credentialMtime?.(id) + return renderStatus(id, whitelistPersisted(cache[id]), { + inflight: input.overlay.has(id), + credential: input.hasCredential(id), + now, + ...(mtime !== undefined ? { mtime } : {}), + ...(session !== undefined ? { session: whitelistPersisted(session) } : {}), + }) + }) + return JSON.stringify({ ok: true, connections, error: null }) +} + +/** GET /amicode/connections — never rejects; failures collapse into the one + * success shape like every other amicode route. Stale connected claims render + * IMMEDIATELY from cache and kick a background revalidation (170 AC1) whose + * result lands in the cache for the NEXT read — the GET never waits. */ +export function statusResponse(deps: { fetchImpl?: FetchImpl } = {}): string { + try { + const body = statusBody({ + file: connectionsFile(), + overlay: inflightOverlay, + hasCredential: (id) => readCredential(id) !== undefined, + credentialMtime: credentialFileMtime, + session: sessionOnlyOverlay, + }) + kickStaleRevalidations(body, deps) + return body + } catch (err) { + return synthesizeConnections("bad_output", String(err)) + } +} + +// --- background revalidation (170 AC1/AC3): stale claims refresh WITHOUT +// blocking the GET that noticed them. Deduped per id; never renders +// "validating" (the card keeps showing the cached claim); every failure is +// swallowed — the next GET simply retries. + +const backgroundInflight = new Map>() + +/** Test seam: a joinable handle over every background revalidation currently + * in flight — await this instead of sleeping. Production never calls it. */ +export function backgroundRevalidationsSettled(): Promise { + return Promise.all([...backgroundInflight.values()]).then(() => undefined) +} + +/** Kick background revalidations for stale connected claims in a just-built + * status body. Skips ids already refreshing, ids with a submit/revalidate in + * flight, session-only claims (nothing at rest to re-check), and ids without + * a stored credential. */ +function kickStaleRevalidations(body: string, deps: { fetchImpl?: FetchImpl }): void { + let entries: unknown + try { + entries = (JSON.parse(body) as { connections?: unknown }).connections + } catch { + return + } + if (!Array.isArray(entries)) return + for (const raw of entries) { + if (typeof raw !== "object" || raw === null) continue + const entry = raw as { id?: unknown; state?: unknown; stale?: unknown; session_only?: unknown } + if (entry.state !== "connected" || entry.stale !== true || entry.session_only === true) continue + const id = CONNECTION_IDS.find((known) => known === entry.id) + if (!id || backgroundInflight.has(id) || inflightOverlay.has(id)) continue + if (readCredential(id) === undefined) continue + const task = (async () => { + try { + if (id === "company-compute") await backgroundRevalidateCompanyCompute(deps) + else backgroundRevalidatePasqal() + } catch { + // background refresh must never surface trouble; the next GET retries + } + })().finally(() => backgroundInflight.delete(id)) + backgroundInflight.set(id, task) + } +} + +/** The metadata a background/manual refresh carries forward from the existing + * cache entry — status facts the probe outcome does not speak to. */ +function keptMetadata(existing: Partial): Partial { + return { + ...(existing.identity ? { identity: existing.identity } : {}), + ...(existing.entitlements ? { entitlements: existing.entitlements } : {}), + ...(existing.expires_at ? { expires_at: existing.expires_at } : {}), + ...(existing.devices ? { devices: existing.devices } : {}), + } +} + +/** Reconcile a revalidation's identity echo against the stored record (170 + * AC4, the 2026-07-19 incident canary). The stored identity is IMMUTABLE + * here: a disagreeing echo lands as identity_drift beside it — never over + * it. No echo → record and any prior drift stand; a matching echo clears + * the drift; a first-ever echo establishes the record. */ +function identityRecord(existing: Partial, submitter: string | undefined): Partial { + if (!submitter) { + return { + ...(existing.identity ? { identity: existing.identity } : {}), + ...(existing.identity_drift ? { identity_drift: existing.identity_drift } : {}), + } + } + if (!existing.identity) return { identity: submitter } + if (existing.identity === submitter) return { identity: existing.identity } + return { identity: existing.identity, identity_drift: submitter } +} + +/** Company-compute background refresh: probe from the STORED credential. + * valid → connected with a fresh validated_at (identity echo reconciled per + * identityRecord); invalid → the authorizer truly rejected the key, render + * it; unreachable → the connected claim and its validated_at STAND (170 + * AC3) — offline trouble is never a verdict on the credential, and the + * credential is never touched. */ +async function backgroundRevalidateCompanyCompute(deps: { fetchImpl?: FetchImpl }): Promise { + const id: ConnectionType = "company-compute" + const credential = readCredential(id) + if (!credential) return + const probe = await probeCompanyCompute(credential.base_url, credential.token, deps.fetchImpl) + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + if (probe.outcome === "unreachable") { + // offline (170 AC3): the connected claim and its last-verified timestamp + // STAND — only the presentation marker lands, and a later successful + // refresh (whose write carries no offline key) clears it + persistStatus(id, { ...existing, offline: true }) + return + } + persistStatus(id, { + ...keptMetadata(existing), + ...(probe.outcome === "valid" + ? identityRecord(existing, probe.submitter) + : existing.identity_drift // a rejection is no reconciliation: a recorded drift stands + ? { identity_drift: existing.identity_drift } + : {}), + state: probe.outcome === "valid" ? "connected" : "invalid", + validated_at: new Date().toISOString(), + }) +} + +/** Pasqal background refresh: the SAME token-mode freshness check the manual + * revalidate runs (169) — local expiry math only, never a validator spawn. */ +function backgroundRevalidatePasqal(): void { + const credential = readCredential("pasqal-cloud") + if (!credential) return + refreshPasqalFreshness(credential) +} + +// --- probe validation --- + +export type ProbeOutcome = "valid" | "invalid" | "unreachable" + +export interface ProbeResult { + outcome: ProbeOutcome + /** identity echo (170 AC4, live endpoint aws-infra#185): present when a + * VALID probe's response body carries a string `submitter` — absent for + * services predating the echo, non-JSON bodies, or rejected keys. */ + submitter?: string +} + +/** Injectable fetch seam — tests stub this; production uses global fetch. + * The status code drives classification; `json` (optional, tolerated + * missing) is the identity-echo seam. */ +export type FetchImpl = ( + url: string, + init: { method: "GET"; headers: Record }, +) => Promise<{ status: number; json?: () => Promise }> + +const PROBE_PATH = "/solves/__validate__/status" + +/** The identity echo: a VALID probe response MAY carry {submitter: string} + * (aws-infra#185). Anything else — no json seam, unparseable body, off-shape + * value — is simply no echo; never a throw, and nothing but the one string + * field is ever read. */ +async function readSubmitterEcho(response: { json?: () => Promise }): Promise { + try { + const raw = await response.json?.() + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + return str((raw as Record).submitter) + } catch { + return undefined + } +} + +/** Classify a Company Compute credential against the fake-task status route + * (parent #159 probe contract: the authorizer rejects bad keys before the + * handler; good keys reach the handler's not-found/forbidden). + * 401 → invalid (authorizer rejected the key) + * 2xx / 403 / 404 → valid (key got past the authorizer) + * anything else → unreachable (service or network trouble) + * The token rides the Authorization header ONLY — never the URL. */ +export async function probeCompanyCompute( + baseUrl: string, + token: string, + fetchImpl: FetchImpl = fetch, +): Promise { + const url = baseUrl.replace(/\/+$/, "") + PROBE_PATH + let response: { status: number; json?: () => Promise } + try { + response = await fetchImpl(url, { method: "GET", headers: { authorization: `Bearer ${token}` } }) + } catch { + return { outcome: "unreachable" } + } + if (response.status === 401) return { outcome: "invalid" } + if ((response.status >= 200 && response.status < 300) || response.status === 403 || response.status === 404) { + const submitter = await readSubmitterEcho(response) + return { outcome: "valid", ...(submitter ? { submitter } : {}) } + } + return { outcome: "unreachable" } +} + +// --- Pasqal validator spawn (amicode#169 / parent #159; #164 contract) --- +// The fork never sees SDK internals: the validator's one-line JSON + exit-code +// contract is the ENTIRE interface. Inputs ride env variables ONLY — never +// argv (visible in `ps`), never files. + +/** Interpreter: $AMICO_PYTHON override → `python3` resolved on PATH. */ +export function pasqalPython(): string { + const env = process.env.AMICO_PYTHON + if (env && env.trim() !== "") return env + return "python3" +} + +/** Script: $AMICO_PASQAL_VALIDATOR override → the amicode-staged copy under + * the SHARED ops-dir resolution (amicodeOpsDir(): $AMICODE_OPS_DIR → + * ~/.amico/amicode). The amicode packaging side stages + * scripts/pasqal-connector/pasqal_validate.py there. */ +export function pasqalValidatorScript(): string { + const env = process.env.AMICO_PASQAL_VALIDATOR + if (env && env.trim() !== "") return env + return path.join(amicodeOpsDir(), "scripts", "pasqal-connector", "pasqal_validate.py") +} + +export interface PasqalValidatorRun { + exitCode: number + stdout: string +} + +/** Injectable spawn seam (AC1): tests record argv + the EXACT child env. The + * default implementation passes both through verbatim — the child env is + * always the minimal declared set built in submitPasqalCredential, NEVER a + * process.env spread. */ +export type PasqalSpawn = (argv: string[], env: Record) => Promise + +const spawnPasqalValidator: PasqalSpawn = async (argv, env) => { + const proc = Bun.spawn(argv as [string, ...string[]], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", // fixed value-free messages by the #164 contract; not consumed here + }) + const stdout = await new Response(proc.stdout).text() + const exitCode = await proc.exited + return { exitCode, stdout } +} + +type PasqalOutcome = + | { kind: "valid"; project_id: string; devices: ConnectionDevice[]; token: string | null; expires_at?: string } + | { kind: "invalid" } + | { kind: "unreachable" } + | { kind: "unentitled" } + | { kind: "config" } + +/** #164 exit-code contract → outcome: 0 valid (stdout must carry ONE + * parseable ok:true JSON line) · 2 invalid-credentials · 3 unreachable · + * 4 project-unauthorized · 1/anything-else config-class (missing env / + * missing SDK / broken interpreter). */ +function classifyValidatorRun(run: PasqalValidatorRun): PasqalOutcome { + if (run.exitCode === 2) return { kind: "invalid" } + if (run.exitCode === 3) return { kind: "unreachable" } + if (run.exitCode === 4) return { kind: "unentitled" } + if (run.exitCode !== 0) return { kind: "config" } + let raw: unknown + try { + raw = JSON.parse(run.stdout.trim()) + } catch { + return { kind: "config" } // exit 0 without the contract line is a config-class lie + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { kind: "config" } + const d = raw as Record + const project = str(d.project_id) + if (d.ok !== true || !project) return { kind: "config" } + const devices: ConnectionDevice[] = [] + if (Array.isArray(d.devices)) { + for (const device of d.devices) { + if (typeof device === "string" && device !== "") devices.push({ name: device }) + else { + const picked = whitelistDevices([device]) // tolerate future object-shaped devices + if (picked) devices.push(...picked) + } + } + } + const token = typeof d.token === "string" && d.token !== "" ? d.token : null + const expires = str(d.expires_at) + return { kind: "valid", project_id: project, devices, token, ...(expires ? { expires_at: expires } : {}) } +} + +// --- status cache writes: everything lands through the same whitelist, so a +// poisoned in-memory object can never serialize, and a poisoned file gets +// scrubbed on the next write. Atomic replace via the #162 writer. + +function persistStatus(id: ConnectionType, entry: Partial): void { + const file = connectionsFile() + const cache = readCacheFile(file) + const out: Record = {} + for (const key of CONNECTION_IDS) if (key in cache) out[key] = whitelistPersisted(cache[key]) + out[id] = whitelistPersisted(entry) + atomicWriteFileSync(file, JSON.stringify(out, null, 2) + "\n") +} + +function clearStatus(id: ConnectionType): void { + const file = connectionsFile() + const cache = readCacheFile(file) + const out: Record = {} + for (const key of CONNECTION_IDS) if (key !== id && key in cache) out[key] = whitelistPersisted(cache[key]) + atomicWriteFileSync(file, JSON.stringify(out, null, 2) + "\n") +} + +// --- HP flip on connect (amicode#167 / parent #159, pushed hp-cloud-key +// contract): a VALID Company Compute save grants the `issimo` entitlement and +// writes the durable {mode:"hp",status:"switching"} request. The amicode +// extension's EXISTING watcher (packages/extension/src/solver_mode.ts, +// watchSolverMode) consumes the request and performs the full re-prep exactly +// once — this slice only WRITES the shared file contract, never a second +// switch mechanism. One-way on connect: disconnect never reverts solver mode +// (the user's toggle owns reverting). + +/** $AMICODE_OPS_DIR override → ~/.amico/amicode — the SAME resolution the + * extension's amicodeOpsDir() uses (substrate/vault_store.ts), so the watcher + * reads exactly where we write and tests stay hermetic. */ +export function amicodeOpsDir(): string { + const env = process.env.AMICODE_OPS_DIR + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "amicode") +} + +export function entitlementsFile(): string { + return path.join(amicodeOpsDir(), "entitlements.toml") +} + +export function solverModeFile(): string { + return path.join(amicodeOpsDir(), "solver-mode.json") +} + +/** Grant `issimo` PRESERVING every other code (read-modify-write). The write + * is byte-compatible with the extension's applyEntitlementForMode writer — + * `codes = [...]` (+ optional `expired = [...]`), double-quoted strings — and + * its smol-toml reader parses it unchanged. Absent/corrupt file starts empty + * (the extension's own fallback); an already-granted file is left untouched + * byte-for-byte. Returns whether the grant was already in place. */ +function grantIssimo(file: string): { alreadyGranted: boolean } { + let codes: string[] = [] + let expired: string[] = [] + try { + const parsed = parseTomlLite(readFileSync(file, "utf8")) + if (parsed.ok) { + const value = parsed.value as { codes?: unknown; expired?: unknown } + if (Array.isArray(value.codes)) codes = value.codes.filter((c): c is string => typeof c === "string") + if (Array.isArray(value.expired)) expired = value.expired.filter((c): c is string => typeof c === "string") + } + } catch { + // absent/unreadable → start empty, matching the extension reader + } + if (codes.includes("issimo")) return { alreadyGranted: true } + codes.push("issimo") + const lines = [`codes = [${codes.map((c) => JSON.stringify(c)).join(", ")}]`] + if (expired.length > 0) lines.push(`expired = [${expired.map((c) => JSON.stringify(c)).join(", ")}]`) + atomicWriteFileSync(file, lines.join("\n") + "\n") + return { alreadyGranted: false } +} + +/** Tolerant {mode,status} read — the extension's readSolverModeState + * semantics: anything absent/off-shape collapses to piccolo/ready. */ +function readSolverMode(file: string): { mode: "piccolo" | "hp"; status: "ready" | "switching" } { + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as { mode?: unknown; status?: unknown } + return { + mode: parsed.mode === "hp" ? "hp" : "piccolo", + status: parsed.status === "switching" ? "switching" : "ready", + } + } catch { + return { mode: "piccolo", status: "ready" } + } +} + +/** The FIXED partial-failure warning (sibling "code: detail" shape): the + * credential save stands; only the flip write went wrong. Value-free by the + * module contract — never a token, path, or errno. */ +export const HP_FLIP_WARNING = "hp_flip_failed: connected, but the HP solver switch could not be requested" + +/** After a VALID save: grant the entitlement, then request the hp switch the + * watcher re-preps from — but ONLY when a re-prep would change anything (the + * mode isn't hp yet, or the last prep ran without the grant). A repeat save + * on an already-flipped setup writes nothing, so the watcher — whose one + * re-prep includes restarting THIS server — is never poked for a no-op. + * NEVER throws: flip trouble must not corrupt the credential-save response; + * the caller passes the returned warning (if any) into the response's error + * field beside the connected status. */ +function requestHpFlip(): string | undefined { + try { + const { alreadyGranted } = grantIssimo(entitlementsFile()) + const modeFile = solverModeFile() + if (alreadyGranted && readSolverMode(modeFile).mode === "hp") return undefined + atomicWriteFileSync(modeFile, JSON.stringify({ mode: "hp", status: "switching" })) + return undefined + } catch { + return HP_FLIP_WARNING + } +} + +// --- mutation bodies (POST routes). One shape per route family, sibling +// discipline: never reject, ok:false + "code: detail" on failure. SECURITY: +// every failure message is a FIXED string — nothing the caller sent (token, +// base_url, anything an encoder rejects) is ever echoed. + +export function synthesizeConnection(code: string, detail: string): string { + return JSON.stringify({ ok: false, connection: null, error: `${code}: ${detail}` }) +} + +// --- loopback guard: credential mutations serve LOCAL callers only. The bind +// hostname is recorded by Server.listen (server.ts) at listen time; the +// in-process webHandler never binds a socket, so "never recorded" counts as +// loopback. setBindHostname doubles as the injectable test seam. + +let bindHostname: string | undefined + +/** Returns the previous value so a listener can RESTORE it when it stops — a + * dead 0.0.0.0 listener must not keep refusing mutations for a later + * loopback/in-process handler (see server.ts). */ +export function setBindHostname(hostname: string | undefined): string | undefined { + const previous = bindHostname + bindHostname = hostname + return previous +} + +/** Same loopback family the mdns gate recognizes (server.ts), widened to the + * whole 127/8 block and the v4-mapped form. undefined = in-process handler. */ +export function isLoopbackHostname(hostname: string | undefined): boolean { + if (hostname === undefined) return true + const host = hostname.toLowerCase() + if (host === "localhost" || host === "::1") return true + if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true + if (host.startsWith("::ffff:127.")) return true + return false +} + +/** The distinct refusal every mutation route answers on a non-loopback bind + * (AC5); undefined when the bind is fine. */ +function loopbackRefusal(bind: string | undefined): string | undefined { + if (isLoopbackHostname(bind)) return undefined + return synthesizeConnection("non_loopback", "credential mutations serve loopback binds only") +} + +const MAX_BODY_BYTES = 16 * 1024 // credentials are small; bigger is a mistake + +export interface MutationDeps { + fetchImpl?: FetchImpl + /** injectable/recordable validator spawn for pasqal-cloud (169 AC1) */ + pasqalSpawn?: PasqalSpawn + /** override the recorded bind hostname (pure-injection alternative to + * setBindHostname) */ + bindHostname?: string +} + +/** `warning` is the partial-failure channel: ok:true (the mutation stood) with + * a non-null error field carrying a FIXED "code: detail" string (#167). */ +function renderCurrent(id: ConnectionType, warning?: string): string { + const cache = readCacheFile(connectionsFile()) + const session = sessionOnlyOverlay.get(id) + const mtime = credentialFileMtime(id) + const connection = renderStatus(id, whitelistPersisted(cache[id]), { + inflight: inflightOverlay.has(id), + credential: readCredential(id) !== undefined, + now: Date.now(), + ...(mtime !== undefined ? { mtime } : {}), + ...(session !== undefined ? { session: whitelistPersisted(session) } : {}), + }) + return JSON.stringify({ ok: true, connection, error: warning ?? null }) +} + +interface MutationBody { + id?: unknown + base_url?: unknown + token?: unknown + username?: unknown + password?: unknown + project_id?: unknown +} + +function parseMutationBody(rawBody: string): MutationBody | undefined { + if (rawBody.length > MAX_BODY_BYTES) return undefined + try { + const parsed: unknown = JSON.parse(rawBody) + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined + return parsed as MutationBody + } catch { + return undefined + } +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" + } catch { + return false + } +} + +/** POST /amicode/connections/credential — body {id:"company-compute", + * base_url, token}. Probe FIRST; only the auth-passed class writes through + * the #162 seam. The terminal status rides back in the SAME response; while + * the probe runs, the overlay renders "validating" for concurrent GETs. */ +export async function submitCredentialResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const body = parseMutationBody(rawBody) + if (!body) return synthesizeConnection("bad_request", "body must be JSON with an id and that id's credential fields") + if (body.id === "pasqal-cloud") return submitPasqalCredential(body, deps) + if (body.id !== "company-compute") { + return synthesizeConnection("unknown_connection", "id must be a known connection id") + } + const base = typeof body.base_url === "string" ? body.base_url.trim().replace(/\/+$/, "") : "" + const token = typeof body.token === "string" ? body.token.trim() : "" + if (base === "" || token === "") + return synthesizeConnection("bad_request", "non-empty base_url and token are required") + if (!isHttpUrl(base)) return synthesizeConnection("bad_request", "base_url must be an http(s) URL") + + const id: ConnectionType = "company-compute" + inflightOverlay.set(id, { state: "validating" }) + let probe: ProbeResult + try { + probe = await probeCompanyCompute(base, token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + const validated_at = new Date().toISOString() + let warning: string | undefined + if (probe.outcome === "valid") { + try { + writeCredential(id, { base_url: base, token }) + } catch { + // value-free by contract: never echo what the encoder rejected + return synthesizeConnection("write_failed", "credential could not be saved") + } + // submitting a credential is the human act that OWNS the identity record + // (170 AC4): the echo (if any) becomes the fresh record, any prior drift + // is reconciled away with the old entry + persistStatus(id, { state: "connected", validated_at, ...(probe.submitter ? { identity: probe.submitter } : {}) }) + warning = requestHpFlip() // #167: AFTER the save and ONLY on the valid outcome + } else { + // nothing written — an existing credential (if any) stays untouched + persistStatus(id, { state: probe.outcome, validated_at }) + } + return renderCurrent(id, warning) +} + +/** POST body {id:"pasqal-cloud", username, password, project_id} → spawn the + * #164 validator (env-only inputs; MINIMAL child env: PATH for interpreter + * resolution plus the three PASQAL_* inputs — never a process.env spread) and + * classify its one-line JSON / exit-code contract. SECURITY: the username and + * password live ONLY in this request scope and the child env — never the + * status cache, never any file, never a log or error message. Pasqal never + * touches solver mode: the HP flip (#167) is company-compute-only. */ +/** The FIXED config-class warning (169 AC5, sibling "code: detail" shape): + * exit 1 / unknown exits / off-contract stdout / spawn failure all mean the + * validator itself could not run properly — distinct from the service being + * unreachable. Value-free by the module contract. */ +export const PASQAL_CONFIG_WARNING = + "pasqal_validator_config: the Pasqal validator could not run — check the Python interpreter and the pasqal-cloud SDK" + +async function submitPasqalCredential(body: MutationBody, deps: MutationDeps): Promise { + const username = typeof body.username === "string" ? body.username.trim() : "" + const password = typeof body.password === "string" ? body.password : "" + const projectId = typeof body.project_id === "string" ? body.project_id.trim() : "" + if (username === "" || password.trim() === "" || projectId === "") + return synthesizeConnection("bad_request", "non-empty username, password and project_id are required") + + const id: ConnectionType = "pasqal-cloud" + const spawn = deps.pasqalSpawn ?? spawnPasqalValidator + const argv = [pasqalPython(), pasqalValidatorScript()] // no secret ever rides argv + const env = { + PATH: process.env.PATH ?? "", // interpreter resolution only + PASQAL_USERNAME: username, + PASQAL_PASSWORD: password, + PASQAL_PROJECT_ID: projectId, + } + inflightOverlay.set(id, { state: "validating" }) + let outcome: PasqalOutcome + try { + outcome = classifyValidatorRun(await spawn(argv, env)) + } catch { + outcome = { kind: "config" } // spawn trouble (missing interpreter/script) is config-class + } finally { + inflightOverlay.delete(id) + } + + const validated_at = new Date().toISOString() + sessionOnlyOverlay.delete(id) // every terminal outcome supersedes a session-only claim + let warning: string | undefined + if (outcome.kind === "valid" && outcome.token !== null) { + try { + // token-only at rest (#162 seam): project_id + token + expiry — the + // password has no field to land in, and the store rejects poison keys. + writeCredential(id, { + project_id: outcome.project_id, + token: outcome.token, + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + } catch { + return synthesizeConnection("write_failed", "credential could not be saved") + } + persistStatus(id, { + state: "connected", + validated_at, + identity: outcome.project_id, + devices: outcome.devices, // non-secret metadata, refreshed on every submit + ...(outcome.expires_at ? { expires_at: outcome.expires_at } : {}), + }) + } else if (outcome.kind === "valid") { + // null token (mint unsupported) → SESSION-ONLY connected (AC4): nothing + // reaches disk; the claim lives in memory and dies with the process, so + // a restarted server re-prompts (needs-key). + clearStatus(id) + sessionOnlyOverlay.set(id, { + state: "connected", + validated_at, + identity: outcome.project_id, + devices: outcome.devices, + }) + } else if (outcome.kind === "config") { + // validator trouble is not a service verdict: render unreachable-class + // with the DISTINCT fixed warning on the #167 partial-trouble channel + persistStatus(id, { state: "unreachable", validated_at }) + warning = PASQAL_CONFIG_WARNING + } else { + // invalid / unreachable / unentitled — nothing written, an existing + // credential (if any) stays untouched + persistStatus(id, { state: outcome.kind, validated_at }) + } + return renderCurrent(id, warning) +} + +/** id-only mutation bodies (disconnect/revalidate) — the secret NEVER rides + * these requests; revalidation reads the stored credential server-side. */ +function parseIdBody(rawBody: string): ConnectionType | undefined { + const body = parseMutationBody(rawBody) + if (!body) return undefined + const id = body.id + if (typeof id !== "string") return undefined + return CONNECTION_IDS.find((known) => known === id) +} + +/** POST /amicode/connections/disconnect — body {id}. Clears the credential + * through the #162 seam and drops the cache entry; status becomes needs-key. + * Idempotent: disconnecting an absent credential is a no-op. */ +export function disconnectResponse(rawBody: string, deps: MutationDeps = {}): string { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const id = parseIdBody(rawBody) + if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + try { + clearCredential(id) + clearStatus(id) + sessionOnlyOverlay.delete(id) // a session-only claim ends with disconnect too + } catch { + return synthesizeConnection("write_failed", "credential could not be cleared") + } + return renderCurrent(id) +} + +/** POST /amicode/connections/revalidate — body {id}. Re-runs the probe from + * the STORED credential and refreshes validated_at; the secret never rides + * the request. Absent credential → needs-key, no probe fired. */ +export async function revalidateResponse(rawBody: string, deps: MutationDeps = {}): Promise { + const refusal = loopbackRefusal(deps.bindHostname ?? bindHostname) + if (refusal) return refusal + const id = parseIdBody(rawBody) + if (!id) return synthesizeConnection("bad_request", "body must be JSON {id} with a known connection id") + if (id === "pasqal-cloud") return revalidatePasqal() + const credential = readCredential("company-compute") + if (!credential) { + clearStatus(id) // a status claim without a credential behind it is noise + return renderCurrent(id) + } + inflightOverlay.set(id, { state: "validating" }) + let probe: ProbeResult + try { + probe = await probeCompanyCompute(credential.base_url, credential.token, deps.fetchImpl) + } finally { + inflightOverlay.delete(id) + } + // credential is kept on EVERY outcome — invalid signals re-entry, it does + // not destroy user data; only disconnect removes the file. Metadata and the + // identity record survive the refresh; a disagreeing echo lands as an + // explicit drift beside the record, never over it (170 AC4). + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + persistStatus(id, { + ...keptMetadata(existing), + ...(probe.outcome === "valid" + ? identityRecord(existing, probe.submitter) + : existing.identity_drift // a failed probe reconciles nothing: a recorded drift stands + ? { identity_drift: existing.identity_drift } + : {}), + state: probe.outcome === "valid" ? "connected" : probe.outcome, + validated_at: new Date().toISOString(), + }) + return renderCurrent(id) +} + +/** Pasqal revalidation is a TOKEN-mode freshness check: the stored credential + * holds only project_id + token — no password — so re-running the validator + * is impossible and pretending otherwise would lie. With a credential, + * expires_at vs now marks connected or expired (validated_at refreshed, + * devices/identity metadata kept); no or unparseable expiry means the claim + * stands. A live token-mode probe against the service is #160's device-path + * territory. Session-only claims are left standing: there is nothing to + * re-check without a password, and revalidate must not destroy them. */ +function revalidatePasqal(): string { + const id: ConnectionType = "pasqal-cloud" + const credential = readCredential(id) + if (!credential) { + if (sessionOnlyOverlay.has(id)) return renderCurrent(id) + clearStatus(id) // a status claim without a credential behind it is noise + return renderCurrent(id) + } + refreshPasqalFreshness(credential) + return renderCurrent(id) +} + +/** The persist half of the Pasqal freshness check — shared by the manual + * revalidate above and the background revalidation (170 AC1): expires_at vs + * now marks connected or expired, validated_at refreshes, devices and other + * metadata survive. */ +function refreshPasqalFreshness(credential: PasqalCredential): void { + const id: ConnectionType = "pasqal-cloud" + const expiresAt = credential.expires_at === undefined ? Number.NaN : Date.parse(credential.expires_at) + const expired = Number.isFinite(expiresAt) && expiresAt <= Date.now() + const existing = whitelistPersisted(readCacheFile(connectionsFile())[id]) + persistStatus(id, { + ...keptMetadata(existing), // devices + any other metadata survive the freshness check + state: expired ? "expired" : "connected", + identity: credential.project_id, + ...(credential.expires_at ? { expires_at: credential.expires_at } : {}), + validated_at: new Date().toISOString(), + }) +} diff --git a/packages/opencode/src/server/amicode/credentials.ts b/packages/opencode/src/server/amicode/credentials.ts new file mode 100644 index 0000000000..a754ba9ef3 --- /dev/null +++ b/packages/opencode/src/server/amicode/credentials.ts @@ -0,0 +1,177 @@ +// AMICODE: the CredentialStore — the ONE seam through which connection +// credentials reach disk (ADR 0001, amicode#159/#162). Per-connection-type +// backends declare their file (env override → ~/.amico default, the +// problems.ts idiom — here the override vars are the SAME ones the amicode +// CLI honors, so the test seam and the CLI-compatibility seam are one +// mechanism) and their schema. Byte shapes are golden-fixture locked +// (test/server/fixtures/credentials): cloud.json must stay parseable by the +// amicode CLI's remote-config reader (amico-run/src/remote_config.ts) +// unchanged. SECURITY: no credential value ever appears in an error message +// or log line — errors carry structure, never bytes. +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" +import { homedir } from "node:os" +import path from "node:path" + +export type ConnectionType = "company-compute" | "pasqal-cloud" + +/** FROZEN byte shape — every existing CLI consumer parses this unchanged. */ +export interface CompanyComputeCredential { + base_url: string + token: string +} +/** Token-only at rest: project id + real token (+ optional expiry metadata). + * A password NEVER has a field to land in — see the poison guard below. */ +export interface PasqalCredential { + project_id: string + token: string + expires_at?: string +} +export type Credential = CompanyComputeCredential | PasqalCredential + +/** $AMICO_CLOUD_FILE overrides the path — the same override the amicode CLI's + * remote-config reader honors (remote_config.ts cloudConfigFile). */ +export function cloudFile(): string { + const env = process.env.AMICO_CLOUD_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "cloud.json") +} +/** $AMICO_PASQAL_FILE override; default lives beside cloud.json. */ +export function pasqalFile(): string { + const env = process.env.AMICO_PASQAL_FILE + if (env && env.trim() !== "") return env + return path.join(homedir(), ".amico", "pasqal.json") +} + +// --- poison guard: writing any object carrying a password-like key through +// this seam must be impossible. The encoders below are allowlist-only (they +// build a fresh object from the declared schema keys), so a stray key can +// never serialize; this guard additionally makes the attempt LOUD instead of +// silently trimmed. The message never echoes the key or its value. +const POISON_KEY = /pass|pwd|user|login|secret/i +function rejectPoisonKeys(value: Record): void { + for (const key of Object.keys(value)) { + if (POISON_KEY.test(key)) throw new Error("credential rejected: password-like keys are never persisted") + } +} + +interface Backend { + file(): string + /** allowlist-encode to the frozen byte shape; throws on schema violations */ + encode(value: Record): string + /** tolerant decode: anything off-schema is absent, never a throw */ + decode(raw: unknown): Credential | undefined +} + +const BACKENDS: Record = { + "company-compute": { + file: cloudFile, + encode(value) { + rejectPoisonKeys(value) + const base = typeof value.base_url === "string" ? value.base_url.trim().replace(/\/+$/, "") : "" + const token = typeof value.token === "string" ? value.token.trim() : "" + if (base === "" || token === "") + throw new Error('company-compute credential needs non-empty "base_url" and "token"') + return JSON.stringify({ base_url: base, token }, null, 2) + "\n" + }, + decode(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + const d = raw as Record + if (typeof d.base_url !== "string" || d.base_url === "") return undefined + if (typeof d.token !== "string" || d.token === "") return undefined + return { base_url: d.base_url.replace(/\/+$/, ""), token: d.token } + }, + }, + "pasqal-cloud": { + file: pasqalFile, + encode(value) { + rejectPoisonKeys(value) + const project = typeof value.project_id === "string" ? value.project_id.trim() : "" + const token = typeof value.token === "string" ? value.token.trim() : "" + if (project === "" || token === "") + throw new Error('pasqal-cloud credential needs non-empty "project_id" and "token"') + const out: PasqalCredential = { project_id: project, token } + if (typeof value.expires_at === "string" && value.expires_at.trim() !== "") + out.expires_at = value.expires_at.trim() + return JSON.stringify(out, null, 2) + "\n" + }, + decode(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined + const d = raw as Record + if (typeof d.project_id !== "string" || d.project_id === "") return undefined + if (typeof d.token !== "string" || d.token === "") return undefined + const out: PasqalCredential = { project_id: d.project_id, token: d.token } + if (typeof d.expires_at === "string" && d.expires_at !== "") out.expires_at = d.expires_at + return out + }, + }, +} + +// --- atomic 0600-at-birth writer --- + +export interface WriteHooks { + /** Test seam: observe or replace the rename step, e.g. to assert the tmp + * file's mode BEFORE it becomes the target (mode-at-birth, never a + * post-rename chmod). Production callers pass nothing. */ + rename?: (tmp: string, target: string) => void +} + +/** Atomic replace: write a sibling tmp file with mode 0600 set AT CREATION + * (the mode option on the open — the Bun/Node default is 0666 & ~umask, + * i.e. world-readable), then rename over the target. rename() swaps the + * inode, so a pre-existing wrong-permission target comes out 0600 too. On + * ANY failure the tmp is removed: the target is never partial — it holds + * either the old bytes or the new bytes, nothing in between. */ +export function atomicWriteFileSync(target: string, data: string, hooks?: WriteHooks): void { + mkdirSync(path.dirname(target), { recursive: true }) + const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${randomBytes(6).toString("hex")}.tmp`) + try { + writeFileSync(tmp, data, { mode: 0o600 }) + ;(hooks?.rename ?? renameSync)(tmp, target) + } catch (err) { + rmSync(tmp, { force: true }) + throw err + } +} + +// --- the seam surface: read / write / clear per connection type --- + +export function readCredential(type: "company-compute"): CompanyComputeCredential | undefined +export function readCredential(type: "pasqal-cloud"): PasqalCredential | undefined +export function readCredential(type: ConnectionType): Credential | undefined +export function readCredential(type: ConnectionType): Credential | undefined { + const backend = BACKENDS[type] + const file = backend.file() + let raw: unknown + try { + if (!existsSync(file)) return undefined + raw = JSON.parse(readFileSync(file, "utf8")) + } catch { + return undefined // missing/unreadable/unparseable → absent, never a throw + } + return backend.decode(raw) +} + +export function writeCredential(type: "company-compute", value: CompanyComputeCredential, hooks?: WriteHooks): void +export function writeCredential(type: "pasqal-cloud", value: PasqalCredential, hooks?: WriteHooks): void +export function writeCredential(type: ConnectionType, value: Credential, hooks?: WriteHooks): void { + const backend = BACKENDS[type] + const bytes = backend.encode(value as unknown as Record) // encode BEFORE touching disk + atomicWriteFileSync(backend.file(), bytes, hooks) +} + +/** Remove the credential file; absent is a no-op. */ +export function clearCredential(type: ConnectionType): void { + rmSync(BACKENDS[type].file(), { force: true }) +} + +/** The credential FILE's mtime in ms — the hand-edit detector (amicode#170 + * AC1): a file newer than its last validation marks the status stale. + * Absent/unreadable → undefined, never a throw. */ +export function credentialFileMtime(type: ConnectionType): number | undefined { + try { + return statSync(BACKENDS[type].file()).mtimeMs + } catch { + return undefined + } +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 0e3daeab91..d5f19ceaea 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -65,6 +65,7 @@ import * as AmicodeDashboard from "@/server/amicode/dashboard" import * as AmicodeWidgetFrame from "@/server/amicode/widget-frame-html" import * as AmicodeLibrary from "@/server/amicode/library" import * as AmicodeProfile from "@/server/amicode/profile" +import * as AmicodeConnections from "@/server/amicode/connections" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { Api } from "@opencode-ai/server/api" @@ -269,9 +270,7 @@ const amicodeProblemsRoute = HttpRouter.use((router) => const amicodeWidgetsRoute = HttpRouter.use((router) => Effect.gen(function* () { yield* router.add("GET", "/amicode/widgets", () => - Effect.sync(() => - HttpServerResponse.text(AmicodeWidgets.widgetsResponse(), { contentType: "application/json" }), - ), + Effect.sync(() => HttpServerResponse.text(AmicodeWidgets.widgetsResponse(), { contentType: "application/json" })), ) // The frame document is served (not srcdoc) so it carries its OWN CSP // header — srcdoc would inherit the app's CSP, which forbids the inline @@ -319,6 +318,43 @@ const amicodeWidgetsRoute = HttpRouter.use((router) => }), ).pipe(Layer.provide(authOnlyRouterLayer)) +// amicode: Connections panel routes (spec #159/S3) — Company Compute connect +// path. Same raw-route idiom + auth as the problems routes; body-builders +// live in amicode/connections.ts and never reject. SECURITY: the credential +// rides the POST BODY (the library idiom) — never query params, never URLs; +// mutation routes refuse non-loopback binds inside the body-builders. +const amicodeConnectionsRoute = HttpRouter.use((router) => + Effect.gen(function* () { + yield* router.add("GET", "/amicode/connections", () => + Effect.sync(() => + HttpServerResponse.text(AmicodeConnections.statusResponse(), { contentType: "application/json" }), + ), + ) + yield* router.add("POST", "/amicode/connections/credential", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + const out = yield* Effect.promise(() => AmicodeConnections.submitCredentialResponse(body)) + return HttpServerResponse.text(out, { contentType: "application/json" }) + }), + ) + yield* router.add("POST", "/amicode/connections/disconnect", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + return HttpServerResponse.text(AmicodeConnections.disconnectResponse(body), { + contentType: "application/json", + }) + }), + ) + yield* router.add("POST", "/amicode/connections/revalidate", (request) => + Effect.gen(function* () { + const body = yield* Effect.orDie(request.text) + const out = yield* Effect.promise(() => AmicodeConnections.revalidateResponse(body)) + return HttpServerResponse.text(out, { contentType: "application/json" }) + }), + ) + }), +).pipe(Layer.provide(authOnlyRouterLayer)) + const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -350,6 +386,7 @@ export function createRoutes( amicodeVaultsRoute, amicodeProblemsRoute, amicodeWidgetsRoute, + amicodeConnectionsRoute, uiRoute, ).pipe( Layer.provide([ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 93e452ced0..e0be7104de 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -6,6 +6,7 @@ import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { createServer } from "node:http" import { MDNS } from "./mdns" +import * as AmicodeConnections from "./amicode/connections" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" @@ -81,7 +82,17 @@ export async function listen(opts: ListenOptions): Promise { const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { - const state = yield* startWithPortFallback(opts) + // amicode: record the bind so credential-mutation routes can refuse to + // serve beyond loopback (amicode#165 AC5). Last listener wins while it + // lives; the previous value is restored when this listener's scope closes + // (or the listen fails), so a dead 0.0.0.0 bind never lingers. The + // in-process webHandler never binds — "never recorded" stays loopback. + const previousBind = yield* Effect.sync(() => AmicodeConnections.setBindHostname(opts.hostname)) + const restoreBind = Effect.sync(() => { + AmicodeConnections.setBindHostname(previousBind) + }) + const state = yield* startWithPortFallback(opts).pipe(Effect.onError(() => restoreBind)) + yield* Scope.addFinalizer(state.scope, restoreBind) const address = yield* tcpAddress(state) const listenerUrl = makeURL(opts.hostname, address.port) url = listenerUrl diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts index fece09592f..4a92c9aeac 100644 --- a/packages/opencode/src/server/shared/public-ui.ts +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -5,8 +5,23 @@ export const PUBLIC_UI_PATHS = new Set([ "/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png", + // Widget frames are iframe DOCUMENT requests — they cannot carry a + // credential (same constraint as the /assets/ sub-resources below). The + // served document embeds only registry widget code + the frame runtime + // (its CSP is default-src 'none'; data arrives via the mediated postMessage + // bridge after boot), so it is public-shell class within the same-user + // trust model. The widget registry route (/amicode/widgets) stays authed. + "/amicode/widget-frame", ]) +// The app shell's fingerprinted bundles live under /assets/. They are plain +//