Skip to content
Closed
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
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Keep image build contexts lean: every Dockerfile in deploy/docker builds from
# the repository root, and without this file the context upload would drag in
# every package's node_modules and build outputs.
**/node_modules
**/dist
**/.git
.git
src-tauri/target
crates/**/target
e2e/test-results
e2e/playwright-report
**/*.log
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
rust: ${{ steps.filter.outputs.rust }}
agent-core: ${{ steps.filter.outputs.agent-core }}
cli: ${{ steps.filter.outputs.cli }}
cloud-runner: ${{ steps.filter.outputs.cloud-runner }}
wasm-artifact: ${{ steps.filter.outputs.wasm-artifact }}
crate-src: ${{ steps.filter.outputs.crate-src }}
steps:
Expand All @@ -50,6 +51,12 @@ jobs:
- 'shared/agent-core/**'
cli:
- 'cli/**'
# cloud-runner/ compiles cli/ and shared/ sources directly, so a change
# to either can break it without touching cloud-runner/ itself.
cloud-runner:
- 'cloud-runner/**'
- 'cli/src/**'
- 'shared/**'
wasm-artifact:
- 'src/acp/iroh/pkg/**'
# Inputs that change the compiled wasm (README.md excluded — it doesn't).
Expand Down Expand Up @@ -221,6 +228,49 @@ jobs:
- name: Run cli tests (5x)
run: cd cli && bun run test:5x

# cloud-runner/ is a standalone service with its own bun.lock, so the `typescript`
# job never reaches it. It type-checks and tests against cli/ and shared/ sources
# in place (no published package), which is why cli/ dependencies are installed
# here too and why the path filter covers cli/src and shared.
cloud-runner:
needs: detect-changes
if: needs.detect-changes.outputs.cloud-runner == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14

- name: Cache Bun dependencies (cloud-runner)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.bun/install/cache
cloud-runner/node_modules
cli/node_modules
key: cloud-runner-bun-${{ hashFiles('cloud-runner/bun.lock', 'cli/bun.lock') }}
restore-keys: |
cloud-runner-bun-

- name: Install dependencies
run: |
cd cli && bun install --frozen-lockfile
cd ../cloud-runner && bun install --frozen-lockfile

- name: Type check cloud-runner
run: cd cloud-runner && bun run typecheck

# The suite boots real servers and streams a stub model, so it is timing
# sensitive on a contended runner — hence the generous per-test timeout.
# shared/artifacts/ rides along: the runner is its only consumer today, and
# the frontend suite's discovery does not reach that directory.
- name: Run cloud-runner tests
run: cd cloud-runner && bun test ./src ../shared/artifacts --timeout 15000 --randomize

# The iroh ACP client (P2P/QUIC crypto core) ships as a prebuilt wasm artifact at
# src/acp/iroh/pkg so the web app imports it without a wasm toolchain in CI. The
# build remaps absolute cargo/repo paths for local reproducibility; cross-platform
Expand Down
7 changes: 7 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ HAYSTACK_API_KEY=
HAYSTACK_WORKSPACE=
HAYSTACK_PIPELINES='[{"id":"rag-chat","name":"RAG Chat","pipelineName":"rag-chat-pipeline","pipelineId":"15cf8b39-0000-0000-0000-000000000000","icon":"book"}]'

# === Cloud runner — optional ===
# Public WebSocket URL of the cloud runner (`cloud-runner/`), the remote
# execution target that keeps built-in-agent turns going after the client
# disconnects. Surfaced to clients via GET /v1/config as `cloudRunner.wsUrl`.
# Leave empty to run every turn on the client.
CLOUD_RUNNER_WS_URL=

# === Trusted proxy ===
# Controls which proxy headers are trusted for IP extraction in rate limiting.
# Set to 'cloudflare' to trust CF-Connecting-IP, 'akamai' for True-Client-IP.
Expand Down
15 changes: 14 additions & 1 deletion backend/src/agents/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const customDescriptor: RemoteAgentDescriptor = {

describe('GET /agents', () => {
/** Env-var keys this suite mutates. Saved + restored to avoid cross-file leakage. */
const envKeys = ['ENABLED_AGENTS', 'ALLOW_CUSTOM_AGENTS'] as const
const envKeys = ['ENABLED_AGENTS', 'ALLOW_CUSTOM_AGENTS', 'CLOUD_RUNNER_WS_URL'] as const
let savedEnv: Partial<Record<(typeof envKeys)[number], string | undefined>>

beforeEach(() => {
Expand Down Expand Up @@ -147,6 +147,19 @@ describe('GET /agents', () => {
expect(body.agents).toEqual([haystackDescriptor, customDescriptor])
})

it('does not advertise the cloud runner as an agent when CLOUD_RUNNER_WS_URL is set', async () => {
// The runner is a placement target for the built-in agent, not an agent of
// its own — its URL reaches clients through GET /config, never discovery.
process.env.CLOUD_RUNNER_WS_URL = 'wss://runner.example/'
clearSettingsCache()
registerAgentProvider({ id: 'haystack', list: () => [haystackDescriptor] })

const app = buildApp(buildAuth({ id: 'user-1', isAnonymous: false }))
const res = await app.handle(new Request('http://localhost/agents'))
const body = await res.json()
expect(body.agents).toEqual([haystackDescriptor])
})

it('isolates failures: a throwing provider does not poison other providers', async () => {
registerAgentProvider({
id: 'broken',
Expand Down
5 changes: 3 additions & 2 deletions backend/src/agents/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import type { AgentsErrorResponse } from './types'
* Settings are read on every request via {@link getSettings} so tests can
* tweak env vars + `clearSettingsCache()` between cases.
*/
export const createAgentsRoutes = (auth: Auth) =>
new Elysia({ name: 'agents-routes', prefix: '/agents' })
export const createAgentsRoutes = (auth: Auth) => {
return new Elysia({ name: 'agents-routes', prefix: '/agents' })
.onError(safeErrorHandler)
.derive(async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers })
Expand Down Expand Up @@ -60,6 +60,7 @@ export const createAgentsRoutes = (auth: Auth) =>
allowCustomAgents: settings.allowCustomAgents,
}
})
}

/**
* Asks every registered provider for its descriptors and concatenates the
Expand Down
109 changes: 105 additions & 4 deletions backend/src/api/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { encryptionMetadataTable, envelopesTable } from '@/db/encryption-schema'
import { chatThreadsTable, devicesTable, settingsTable, tasksTable } from '@/db/schema'
import { hashCanarySecret } from '@/lib/canary'
import { createTestDb } from '@/test-utils/db'
import { createTestSettings } from '@/test-utils/settings'
import { createHmac } from 'crypto'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { Elysia } from 'elysia'
import { createAccountRoutes } from './account'
import { createAccountRoutes, type AccountRoutesDeps } from './account'

const betterAuthSecret = 'better-auth-secret-12345678901234567890'
const signToken = (token: string): string => {
Expand Down Expand Up @@ -42,6 +43,8 @@ describe('Account API', () => {
let app: ReturnType<typeof createAccountRoutes>
let db: Awaited<ReturnType<typeof createTestDb>>['db']
let cleanup: () => Promise<void>
/** Built per test alongside the app so purge cases can mount their own deps. */
let buildApp: (deps?: AccountRoutesDeps) => ReturnType<typeof createAccountRoutes>
/** Prefix IDs with the current runId — see top-of-file comment for why. */
let p: (id: string) => string

Expand All @@ -52,9 +55,11 @@ describe('Account API', () => {
db = testEnv.db
cleanup = testEnv.cleanup
const auth = createAuth(db)
app = new Elysia({ prefix: '/v1' }).use(createAccountRoutes(auth, db)) as unknown as ReturnType<
typeof createAccountRoutes
>
buildApp = (deps?: AccountRoutesDeps) =>
new Elysia({ prefix: '/v1' }).use(createAccountRoutes(auth, db, deps)) as unknown as ReturnType<
typeof createAccountRoutes
>
app = buildApp()
})

afterEach(async () => {
Expand Down Expand Up @@ -460,6 +465,102 @@ describe('Account API', () => {
})
})

describe('DELETE /v1/account (cloud runner purge)', () => {
type PurgeCall = { url: string; authorization: string | undefined; userStillExists: boolean }

/** Captures each purge request plus whether the user row still exists at that
* moment — the runner authorizes /purge by introspecting the forwarded
* bearer, so the call must land before the session rows are gone. */
const capturePurge = (calls: PurgeCall[], userId: string, status: number): typeof fetch => {
const impl = async (input: URL | RequestInfo, init?: RequestInit): Promise<Response> => {
const rows = await db.select().from(user).where(eq(user.id, userId))
calls.push({
url: input.toString(),
authorization: (init?.headers as Record<string, string>)?.authorization,
userStillExists: rows.length > 0,
})
return new Response(null, { status })
}
return Object.assign(impl, { preconnect: () => {} }) as unknown as typeof fetch
}

const deleteAccount = (appUnderTest: ReturnType<typeof createAccountRoutes>, token: string): Promise<Response> =>
appUnderTest.handle(
new Request('http://localhost/v1/account', {
method: 'DELETE',
headers: { Authorization: `Bearer ${signToken(token)}` },
}),
)

it('purges the runner with the forwarded bearer before deleting the user', async () => {
const userId = p('purge-user')
const token = p('purge-token')
await createUserSessionAndDevice(userId, token, p('purge-device'))

const calls: PurgeCall[] = []
const purgeApp = buildApp({
settings: createTestSettings({ cloudRunnerWsUrl: 'wss://runner.example/' }),
fetchFn: capturePurge(calls, userId, 204),
})

const response = await deleteAccount(purgeApp, token)
expect(response.status).toBe(204)

expect(calls).toEqual([
{
url: 'https://runner.example/purge',
authorization: `Bearer ${signToken(token)}`,
userStillExists: true,
},
])
expect(await db.select().from(user).where(eq(user.id, userId))).toHaveLength(0)
})

it('deletes the account and logs an error when the runner purge keeps failing', async () => {
const userId = p('purge-fail-user')
const token = p('purge-fail-token')
await createUserSessionAndDevice(userId, token, p('purge-fail-device'))

const calls: PurgeCall[] = []
const logged: { context: Record<string, unknown>; message: string }[] = []
const purgeApp = buildApp({
settings: createTestSettings({ cloudRunnerWsUrl: 'wss://runner.example/' }),
fetchFn: capturePurge(calls, userId, 503),
logger: { error: (context, message) => logged.push({ context, message }) },
})

const response = await deleteAccount(purgeApp, token)
expect(response.status).toBe(204)

// One retry before giving up.
expect(calls).toHaveLength(2)
expect(logged).toHaveLength(1)
expect(logged[0].context).toEqual({ userId, reason: 'status 503' })
expect(await db.select().from(user).where(eq(user.id, userId))).toHaveLength(0)
})

it('does not call the runner when no runner is configured', async () => {
const userId = p('purge-unset-user')
const token = p('purge-unset-token')
await createUserSessionAndDevice(userId, token, p('purge-unset-device'))

const calls: PurgeCall[] = []
const logged: { context: Record<string, unknown>; message: string }[] = []
const purgeApp = buildApp({
settings: createTestSettings(),
fetchFn: capturePurge(calls, userId, 204),
logger: { error: (context, message) => logged.push({ context, message }) },
})

const response = await deleteAccount(purgeApp, token)
expect(response.status).toBe(204)

expect(calls).toHaveLength(0)
expect(logged).toHaveLength(0)
expect(await db.select().from(user).where(eq(user.id, userId))).toHaveLength(0)
})
})

describe('POST /v1/account/devices/:id/revoke (canary proof)', () => {
it('returns 401 without auth', async () => {
const response = await app.handle(
Expand Down
34 changes: 32 additions & 2 deletions backend/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import type { Auth } from '@/auth/elysia-plugin'
import { createAuthMacro } from '@/auth/elysia-plugin'
import { purgeCloudRunnerData } from '@/cloud-runner/purge'
import { getSettings, type Settings } from '@/config/settings'
import {
deleteUser,
revokeDevice,
Expand All @@ -17,8 +19,24 @@ import { verifyCanaryProofWithMetadata } from '@/lib/canary'
import { safeErrorHandler } from '@/middleware/error-handling'
import { Elysia, t } from 'elysia'

/** Minimal logger surface these routes use — narrower than Pino so tests can
* pass a one-method recorder without dragging in the full type. */
export type AccountLogger = {
error: (context: Record<string, unknown>, message: string) => void
}

export type AccountRoutesDeps = {
settings?: Settings
fetchFn?: typeof fetch
logger?: AccountLogger
}

/** Account API routes. All routes require authentication. */
export const createAccountRoutes = (auth: Auth, database: typeof DbType) => {
export const createAccountRoutes = (auth: Auth, database: typeof DbType, deps: AccountRoutesDeps = {}) => {
const settings = deps.settings ?? getSettings()
const fetchFn = deps.fetchFn ?? globalThis.fetch
const logger = deps.logger

return new Elysia({ prefix: '/account' })
.onError(safeErrorHandler)
.use(createAuthMacro(auth))
Expand Down Expand Up @@ -75,7 +93,19 @@ export const createAccountRoutes = (auth: Auth, database: typeof DbType) => {
)
.delete(
'/',
async ({ set, user }) => {
async ({ request, set, user }) => {
const purgeFailure = await purgeCloudRunnerData({
settings,
authorization: request.headers.get('authorization'),
fetchFn,
})
if (purgeFailure) {
// Deleting the account wins over purging the runner: a runner outage
// must not leave the user unable to delete, and the runner's retention
// TTL reclaims whatever this call failed to remove.
logger?.error({ userId: user.id, reason: purgeFailure }, 'cloud runner purge failed; deleting account anyway')
}

// tables have cascade delete on user_id and they will be deleted automatically
await deleteUser(database, user.id)

Expand Down
20 changes: 20 additions & 0 deletions backend/src/api/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ describe('Config Routes', () => {
expect(body.minAppVersion).toBe('0.2.0')
})

it('exposes cloudRunner.wsUrl when CLOUD_RUNNER_WS_URL is set', async () => {
const { body } = await fetchConfig(createTestSettings({ cloudRunnerWsUrl: 'wss://runner.example/' }))
expect(body.cloudRunner).toEqual({ wsUrl: 'wss://runner.example/' })
})

it('trims whitespace around the configured runner URL', async () => {
const { body } = await fetchConfig(createTestSettings({ cloudRunnerWsUrl: ' wss://runner.example/ ' }))
expect(body.cloudRunner).toEqual({ wsUrl: 'wss://runner.example/' })
})

it('omits cloudRunner when CLOUD_RUNNER_WS_URL is unset', async () => {
const { body } = await fetchConfig(createTestSettings())
expect(body).not.toHaveProperty('cloudRunner')
})

it('omits cloudRunner when CLOUD_RUNNER_WS_URL is only whitespace', async () => {
const { body } = await fetchConfig(createTestSettings({ cloudRunnerWsUrl: ' ' }))
expect(body).not.toHaveProperty('cloudRunner')
})

it('does not require authentication', async () => {
const { status } = await fetchConfig(createTestSettings())
expect(status).toBe(200)
Expand Down
11 changes: 9 additions & 2 deletions backend/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,26 @@ import { Elysia } from 'elysia'
* by comparing versions, so shipped defaults changes don't require a client
* release. See "Reconciled defaults and version bumps" in AGENTS.md.
*/
export const createConfigRoutes = (settings: Settings) =>
new Elysia({ prefix: '/config' }).onError(safeErrorHandler).get('/', () => ({
export const createConfigRoutes = (settings: Settings) => {
const cloudRunnerWsUrl = settings.cloudRunnerWsUrl.trim()

return new Elysia({ prefix: '/config' }).onError(safeErrorHandler).get('/', () => ({
e2eeEnabled: settings.e2eeEnabled,
// Inverted so the env reads as an opt-in switch ("disable") while the wire
// contract reads as a positive capability ("enabled").
builtInAgentEnabled: !settings.disableBuiltInAgent,
allowCustomAgents: settings.allowCustomAgents,
// Omit when unset so the frontend treats it as "no enforcement" without parsing an empty string as semver.
minAppVersion: settings.minAppVersion || undefined,
// Where the built-in agent's turns can run instead of on the client, so they
// survive the tab closing. Omitted when no runner is deployed. Not a
// credential — the runner authenticates every socket it accepts.
cloudRunner: cloudRunnerWsUrl ? { wsUrl: cloudRunnerWsUrl } : undefined,
defaults: {
models: {
version: defaultModelsVersion,
data: defaultModels,
},
},
}))
}
Loading
Loading