Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/skills/agentconnect-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>: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`:
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>:3443` and trust the generated root certificate once per
device (export it with `docker compose -f compose.yaml -f compose.https.yaml cp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Carry the required host into the certificate-export command. The preceding AGENTCONNECT_HTTPS_HOST=... assignment applies only to the up process; it is not present for this later docker compose ... cp. Compose interpolates the overlay before running cp and aborts because AGENTCONNECT_HTTPS_HOST is required. Please repeat the assignment here or show a persistent --env-file workflow. The analogous command in compose.https.yaml also needs the overlay file set (and the host value).

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).
Expand Down
9 changes: 9 additions & 0 deletions compose.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions compose.https.yaml
Original file line number Diff line number Diff line change
@@ -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=<LAN IP or hostname> \
# docker compose -f compose.yaml -f compose.https.yaml up -d
#
# Then open https://<host>: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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep daemon onboarding usable with the internal CA. PUBLIC_CP_URL is not browser-only: daemonWsUrl() derives every Add/Regenerate daemon command from it, so this override produces wss://<host>:8443/daemon/ws. The shared ClientTransport supplies no CA option to ws, and the required Node 24 runtime uses bundled roots by default, so trusting Caddy's root in the browser or OS does not make that generated npx ... run command trust it. The daemon therefore reconnects forever in the advertised LAN setup. Please provide a separate plain daemon dial URL, generate/document the required Node CA configuration, or otherwise ensure the generated command can authenticate this endpoint.

- 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:
20 changes: 20 additions & 0 deletions docker/caddy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 4 additions & 7 deletions packages/web/src/components/console/PlaygroundProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -176,11 +177,7 @@ function liveActivityStamp(): Pick<Session, 'lastActivityAt' | 'time'> {
}

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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/components/console/modals/AddCronModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 ?? {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/lib/data-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]
Expand Down
30 changes: 30 additions & 0 deletions packages/web/src/lib/random-id.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
15 changes: 15 additions & 0 deletions packages/web/src/lib/random-id.ts
Original file line number Diff line number Diff line change
@@ -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)}`
}