diff --git a/.claude/skills/agentconnect-setup/SKILL.md b/.claude/skills/agentconnect-setup/SKILL.md index 50d9b0eef..0f680687e 100644 --- a/.claude/skills/agentconnect-setup/SKILL.md +++ b/.claude/skills/agentconnect-setup/SKILL.md @@ -10,14 +10,14 @@ Guide the user one checkpoint at a time. Select one deployment mode, load only i ## Establish the session 1. Determine the AgentConnect checkout or deployment directory. Prefer the current directory when it contains `compose.yaml`; otherwise ask for or locate the checkout. -2. Ask whether access is loopback-only, LAN-only, or through stable public domains using Cloudflare Tunnel or another reverse proxy. Never assume `localhost`. If anything is externally reachable, read [references/network-topology.md](references/network-topology.md) and collect the final topology before continuing. +2. Ask whether access is loopback-only, LAN-only, or through stable public domains using Cloudflare Tunnel or another reverse proxy. Never assume `localhost`. If anything is reachable beyond loopback (LAN included), read [references/network-topology.md](references/network-topology.md) and collect the final topology before continuing. 3. Determine the target mode: - `no-auth`: loopback-only local evaluation; - `local-logto`: local evaluation with the bundled Logto OSS overlay; - `cloud-logto`: a locally or single-hosted Compose stack using Logto Cloud. 4. Determine whether the user wants only the core stack or also Google, GitHub, Slack, a daemon, and production hardening. 5. Read [references/deployment-modes.md](references/deployment-modes.md) plus [references/local-logto.md](references/local-logto.md) or [references/cloud-logto.md](references/cloud-logto.md) only when that mode is selected. Read [references/integrations.md](references/integrations.md) only for requested providers. Read [references/operations.md](references/operations.md) for upgrades, reset, remote-daemon networking, or troubleshooting. -6. Inspect the checkout's `compose.yaml`, optional `compose.logto.yaml`, and available environment template before issuing commands. If the checkout differs from the bundled references, follow the checkout and current official documentation, and explain the difference. +6. Inspect the checkout's `compose.yaml`, optional `compose.logto.yaml` and `compose.https.yaml` overlays, and available environment template before issuing commands. If the checkout differs from the bundled references, follow the checkout and current official documentation, and explain the difference. For an external topology, obtain these values before starting authentication or provider setup: diff --git a/.claude/skills/agentconnect-setup/references/network-topology.md b/.claude/skills/agentconnect-setup/references/network-topology.md index 9ed8a4219..8661a2b45 100644 --- a/.claude/skills/agentconnect-setup/references/network-topology.md +++ b/.claude/skills/agentconnect-setup/references/network-topology.md @@ -19,6 +19,10 @@ Ask for the following without requesting credentials: Record the answers as a small table before changing `compose.env`. Do not assume the four AgentConnect addresses share a hostname. Prefer separate origin-level hostnames because the configured values are origins without trailing slashes; use path-based routing only after verifying the current release and proxy preserve every callback and WebSocket path. +## LAN-only browser access + +Browsers grant secure-context APIs (`crypto.randomUUID`, `crypto.subtle`, clipboard) only over HTTPS or localhost, so a console opened from another LAN device by plain HTTP degrades. For LAN-only deployments without a tunnel, use the `compose.https.yaml` overlay: set `AGENTCONNECT_HTTPS_HOST` to the LAN IP or hostname, add `-f compose.https.yaml` to the Compose invocation, open `https://:3443`, and have each device trust the exported Caddy root certificate (or accept the warning once per HTTPS origin: `:3443`, `:8443`, `:9443`). Daemons keep using the plain HTTP/WS ports. + ## Typical tunnel mapping A tunnel agent on the Compose host can connect to loopback-bound ports, so external access usually does not require changing `AGENTCONNECT_BIND_ADDRESS`: diff --git a/README.md b/README.md index 4d5a513cd..d604954c6 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,23 @@ Continue with the browser-based local-auth bootstrap in the [`@agentconnect.md/setup` walkthrough](packages/setup/README.md). The default no-auth Compose command above is unchanged. +To reach the console from **other devices on your LAN** (for example a stack on +a NAS opened by IP), use the HTTPS overlay. Browsers grant `crypto.randomUUID`, +`crypto.subtle`, and clipboard access only to secure contexts — HTTPS or +localhost — so a console served over plain HTTP from a non-localhost address +degrades. The overlay fronts the stack with Caddy and an internal CA: + +```bash +AGENTCONNECT_HTTPS_HOST='your LAN IP or hostname' docker compose -f compose.yaml -f compose.https.yaml up -d +``` + +Open `https://:3443` and trust the generated root certificate once per +device (export it with `docker compose -f compose.yaml -f compose.https.yaml cp +caddy:/data/caddy/pki/authorities/local/root.crt agentconnect-root-ca.crt`), or +visit each of the three HTTPS origins (`:3443`, `:8443`, `:9443`) once and +accept the warning. Daemons keep dialing the plain HTTP/WS ports and are +unaffected. Do not expose a no-auth stack beyond your trusted network. + For image pinning, production networking, sign-in, secrets, GitHub App setup, and optional Mem0 configuration, see the [AgentConnect OSS guide](https://docs.agentconnect.md/docs/oss-get-started). diff --git a/compose.env.example b/compose.env.example index d40ee3d90..79e166a45 100644 --- a/compose.env.example +++ b/compose.env.example @@ -17,6 +17,15 @@ # AGENTCONNECT_PUBLIC_RELAY_URL=http://localhost:8090 # AGENTCONNECT_RELAY_DAEMON_URL=ws://localhost:8090 +# HTTPS overlay (compose.https.yaml) for browsers on other LAN devices: Caddy +# terminates TLS with an internal CA so the console runs in a secure context. +# Required when using the overlay; the rest default to the values shown. +# AGENTCONNECT_HTTPS_HOST=192.0.2.10 +# AGENTCONNECT_TLS_BIND_ADDRESS=0.0.0.0 +# AGENTCONNECT_WEB_TLS_PORT=3443 +# AGENTCONNECT_CP_TLS_PORT=8443 +# AGENTCONNECT_RELAY_TLS_PORT=9443 + # Replace all three before using the stack beyond throwaway local evaluation. # Use URL-safe characters for the Postgres password because it is embedded in a # connection URL. API_KEY_PEPPER and RELAY_TOKEN must each be at least 32 chars. diff --git a/compose.https.yaml b/compose.https.yaml new file mode 100644 index 000000000..18241545d --- /dev/null +++ b/compose.https.yaml @@ -0,0 +1,64 @@ +# HTTPS overlay for LAN deployments (e.g. a NAS reached by IP or intranet name). +# Browsers expose crypto.randomUUID, crypto.subtle, and navigator.clipboard only +# in secure contexts (HTTPS or localhost), so a console served over plain HTTP +# from a non-localhost host degrades. This overlay puts Caddy in front of web, +# control-plane, and relay with certificates from Caddy's internal CA: +# +# AGENTCONNECT_HTTPS_HOST= \ +# docker compose -f compose.yaml -f compose.https.yaml up -d +# +# Then open https://:3443. Trust the generated CA once per device +# (recommended; otherwise visit each of the three HTTPS origins once and accept +# the certificate warning): +# +# docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt agentconnect-root-ca.crt +# +# Daemons are unaffected by secure contexts and keep dialing the plain HTTP/WS +# ports, so AGENTCONNECT_BIND_ADDRESS / AGENTCONNECT_RELAY_DAEMON_URL work as in +# the base file. Do not expose a no-auth stack beyond your trusted network. + +services: + caddy: + image: caddy:2-alpine + depends_on: + web: + condition: service_healthy + control-plane: + condition: service_healthy + relay: + condition: service_healthy + environment: + AGENTCONNECT_HTTPS_HOST: ${AGENTCONNECT_HTTPS_HOST:?set AGENTCONNECT_HTTPS_HOST to the LAN hostname or IP browsers will use} + ports: + - ${AGENTCONNECT_TLS_BIND_ADDRESS:-0.0.0.0}:${AGENTCONNECT_WEB_TLS_PORT:-3443}:3443 + - ${AGENTCONNECT_TLS_BIND_ADDRESS:-0.0.0.0}:${AGENTCONNECT_CP_TLS_PORT:-8443}:8443 + - ${AGENTCONNECT_TLS_BIND_ADDRESS:-0.0.0.0}:${AGENTCONNECT_RELAY_TLS_PORT:-9443}:9443 + restart: unless-stopped + volumes: + - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + # Persists the internal CA so trusted roots survive container recreation. + - caddy-data:/data + - caddy-config:/config + + setup-server: + environment: + - AGENTCONNECT_PUBLIC_CP_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_CP_TLS_PORT:-8443} + - AGENTCONNECT_PUBLIC_WEB_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_WEB_TLS_PORT:-3443} + - AGENTCONNECT_PUBLIC_RELAY_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_RELAY_TLS_PORT:-9443} + + control-plane: + environment: + - PUBLIC_CP_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_CP_TLS_PORT:-8443} + - PUBLIC_WEB_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_WEB_TLS_PORT:-3443} + - PUBLIC_RELAY_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_RELAY_TLS_PORT:-9443} + - CORS_ORIGIN=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_WEB_TLS_PORT:-3443} + + web: + environment: + - PUBLIC_WEB_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_WEB_TLS_PORT:-3443} + - CP_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_CP_TLS_PORT:-8443}/api/v1 + - RELAY_URL=https://${AGENTCONNECT_HTTPS_HOST}:${AGENTCONNECT_RELAY_TLS_PORT:-9443} + +volumes: + caddy-data: + caddy-config: diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile new file mode 100644 index 000000000..1941add11 --- /dev/null +++ b/docker/caddy/Caddyfile @@ -0,0 +1,20 @@ +# TLS termination for LAN deployments (compose.https.yaml). One HTTPS port per +# service so no route knowledge leaks into the proxy; certificates come from +# Caddy's internal CA (trust root.crt once per device — see compose.https.yaml). +{ + local_certs + admin off + auto_https disable_redirects +} + +https://{$AGENTCONNECT_HTTPS_HOST}:3443 { + reverse_proxy web:8080 +} + +https://{$AGENTCONNECT_HTTPS_HOST}:8443 { + reverse_proxy control-plane:8080 +} + +https://{$AGENTCONNECT_HTTPS_HOST}:9443 { + reverse_proxy relay:8080 +} diff --git a/packages/web/src/components/console/PlaygroundProvider.tsx b/packages/web/src/components/console/PlaygroundProvider.tsx index 20478b52b..27b7305b8 100644 --- a/packages/web/src/components/console/PlaygroundProvider.tsx +++ b/packages/web/src/components/console/PlaygroundProvider.tsx @@ -37,6 +37,7 @@ import { type SessionMessageDto } from '@/lib/api' import { useOrgs } from '@/lib/org-context' +import { randomUUID } from '@/lib/random-id' import { resolveRoster, typedMentionIds, wireMentions } from '@/lib/conversation-addressing' import { sessionAfterModelSelection } from '@/lib/session-runtime-controls' import { reconcilePersistedLiveSteps } from '@/lib/session-transcript' @@ -176,11 +177,7 @@ function liveActivityStamp(): Pick { } function newPlaygroundSessionId(agentId: string): string { - const entropy = - typeof crypto !== 'undefined' && crypto.randomUUID - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` - return `${PG_PREFIX}${agentId}_${entropy}` + return `${PG_PREFIX}${agentId}_${randomUUID()}` } function stampStep(step: SessionStep, observedAtMs = Date.now()): SessionStep { @@ -1129,7 +1126,7 @@ export function PlaygroundProvider({ children }: { children: ReactNode }) { conversationId?: string, knownParticipants?: Array<{ agentId: string; name: string; primary?: boolean }> ): void => { - const requestedTurnId = crypto.randomUUID() + const requestedTurnId = randomUUID() pushStep(id, { kind: 'msg', who: '@you', turnId: requestedTurnId, text, ...(image ? { image } : {}) }) setBusy(id, true) // Targeting (webchat-multi-agents.md §4.2): conversation membership is a @@ -1242,7 +1239,7 @@ export function PlaygroundProvider({ children }: { children: ReactNode }) { // between a turn's end and the dispatch of the queue head stays FIFO. if (busyRef.current[id] || (pgQueueRef.current[id]?.length ?? 0) > 0) { const queued: QueuedTurn = { - queueId: crypto.randomUUID(), + queueId: randomUUID(), text, ...(image ? { image } : {}), agentId: agentForId, diff --git a/packages/web/src/components/console/modals/AddCronModal.tsx b/packages/web/src/components/console/modals/AddCronModal.tsx index 4ec482f84..0cebd1b9b 100644 --- a/packages/web/src/components/console/modals/AddCronModal.tsx +++ b/packages/web/src/components/console/modals/AddCronModal.tsx @@ -19,6 +19,7 @@ import { } from '@/lib/cron' import { useConsoleData } from '@/lib/data-context' import { useProfile } from '@/lib/profile' +import { randomUUID } from '@/lib/random-id' import { AgentIconView, PlatformMark } from '@/components/marks' import { Button, Icon } from '@/components/ui' import { VisibilityField, sameSharing, type SharingValue } from '@/components/console/VisibilityField' @@ -201,7 +202,7 @@ export default function AddCronModal({ cron, onClose }: { cron?: CronDto | null; ? { integrationId: selectedOpt.integrationId, channel: selectedOpt.channelId, platform: selectedOpt.platform } : target const creating = !cron - const cronId = cron?.id ?? crypto.randomUUID() + const cronId = cron?.id ?? randomUUID() const existingInput = cron ? cronUpdateInput(cron) : null await saveCron(cronId, { ...(existingInput ?? {}), diff --git a/packages/web/src/components/console/platforms/telegram/Body.tsx b/packages/web/src/components/console/platforms/telegram/Body.tsx index 9cfe0da94..18ee0cab7 100644 --- a/packages/web/src/components/console/platforms/telegram/Body.tsx +++ b/packages/web/src/components/console/platforms/telegram/Body.tsx @@ -5,6 +5,7 @@ import useSWR from 'swr' import { PlatformMark } from '@/components/marks' import { Icon } from '@/components/ui' import { checkTelegramBot, type TelegramBotCheckDto } from '@/lib/api' +import { randomUUID } from '@/lib/random-id' import type { Agent } from '@/lib/data' import type { WizardHost } from '../contract' import { usePublishedFooter } from '../publish' @@ -89,7 +90,7 @@ export function TelegramWizardBody({ agent, host }: { agent: Agent; host: Wizard const tokenTrim = botToken.trim() const telegramOk = /^\d+:[A-Za-z0-9_-]{20,}$/.test(tokenTrim) - const [checkScope] = useState(() => crypto.randomUUID()) + const [checkScope] = useState(() => randomUUID()) const checkSequence = useRef(0) const [checkRequest, setCheckRequest] = useState<{ token: string; sequence: number } | null>(null) const checkEnabled = host.mode === 'create' && telegramOk diff --git a/packages/web/src/lib/data-context.tsx b/packages/web/src/lib/data-context.tsx index d4461be37..bf7b09f2e 100644 --- a/packages/web/src/lib/data-context.tsx +++ b/packages/web/src/lib/data-context.tsx @@ -12,6 +12,7 @@ import useSWR, { useSWRConfig } from 'swr' import { useOrgs } from '@/lib/org-context' import { consoleKeys } from '@/lib/swr-keys' import { useSessionList } from '@/lib/use-session-list' +import { randomUUID } from '@/lib/random-id' import { accessNotificationSnapshot, type AccessNotificationSnapshot } from '@/lib/access-notification-snapshot' import { AGENTS, @@ -1350,7 +1351,7 @@ export function ConsoleDataProvider({ children }: { children: ReactNode }) { // just mints a fresh client-side UUID (the CP keys the row on it). const saveCron = useCallback( async (id: string | null, body: UpsertCronInput) => { - await apiUpsertCron(id ?? crypto.randomUUID(), body) + await apiUpsertCron(id ?? randomUUID(), body) settleInBackground(mutateCrons()) }, [mutateCrons] diff --git a/packages/web/src/lib/random-id.test.ts b/packages/web/src/lib/random-id.test.ts new file mode 100644 index 000000000..dd28b5490 --- /dev/null +++ b/packages/web/src/lib/random-id.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from './random-id' + +const V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe('randomUUID', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('uses the native implementation when available', () => { + const native = vi.fn(() => '11111111-2222-4333-8444-555555555555') + vi.stubGlobal('crypto', { randomUUID: native }) + expect(randomUUID()).toBe('11111111-2222-4333-8444-555555555555') + expect(native).toHaveBeenCalledOnce() + }) + + it('builds a valid v4 UUID over HTTP, where only getRandomValues exists', () => { + // Insecure contexts (HTTP on a LAN host) hide crypto.randomUUID but keep + // getRandomValues — the frames' z.string().uuid() must still accept the id. + const real = globalThis.crypto + vi.stubGlobal('crypto', { getRandomValues: real.getRandomValues.bind(real) }) + const seen = new Set(Array.from({ length: 64 }, () => randomUUID())) + for (const id of seen) expect(id).toMatch(V4) + expect(seen.size).toBe(64) + }) + + it('still emits v4-shaped ids with no crypto object at all', () => { + vi.stubGlobal('crypto', undefined) + expect(randomUUID()).toMatch(V4) + }) +}) diff --git a/packages/web/src/lib/random-id.ts b/packages/web/src/lib/random-id.ts new file mode 100644 index 000000000..9d35cae1b --- /dev/null +++ b/packages/web/src/lib/random-id.ts @@ -0,0 +1,15 @@ +// Browsers expose crypto.randomUUID only in secure contexts (HTTPS/localhost) — +// an HTTP console on a LAN host needs this manual v4 fallback (wire frames +// validate ids with z.string().uuid(), so the fallback must be a real UUID). +export function randomUUID(): string { + const c = globalThis.crypto + if (c?.randomUUID) return c.randomUUID() + const bytes = new Uint8Array(16) + // getRandomValues is NOT gated on secure contexts; Math.random is a last resort. + if (c?.getRandomValues) c.getRandomValues(bytes) + else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 // version 4 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 // RFC 4122 variant + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +}