From 1430df668184914727b523f8de1173f140c31385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Thu, 30 Apr 2026 03:36:16 +0100 Subject: [PATCH] feat: lobu apply hardening + e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things on top of the merged lobu apply v1: Bug A — PUT /agents/:id/connections/by-stable-id/:stableId now folds `settings` (allowFrom, allowGroups, …) into the noop comparison so settings-only edits trip willRestart instead of being silently noop'd. Symmetric default `allowGroups: true` on the create-fallback path keeps follow-up PUTs round-tripping as noop. Bug B — `diff.canonical` no longer collapses `[]` and `{}` to null; clearing a remote allowlist by setting it to `[]` now produces an `update` action. The `platform` key the route injects into stored connection config is stripped before comparing so an unchanged connection round-trips as `=`. Bootstrap PAT — `start-local.ts` mints a default user / org `dev` / owner-scoped PAT on first boot under `LOBU_LOCAL_BOOTSTRAP=true`, prints it once with the org slug + URL, and persists it under `${OWLETTO_DATA_DIR}/bootstrap-pat.txt`. Production never sets the flag, so the path is dead in cloud. Required collateral: `multi-tenant.ts` now hydrates `c.var.user` for PAT bearers so REST routes that read `c.get('user')` (e.g. POST /agents) work the same as for cli-tokens; the asymmetry was previously masked by the test mock setting `user` directly. CLI client.ts dropped the trailing slash on POST /api/:org/agents — Hono `app.route()` does not match `path/` against `routes.post('/')`. `scripts/e2e-lobu-apply.sh` exercises create → noop → update → drift detect against PGlite end-to-end, asserting REST rows landed. Self-cleans server, data dir, and project dir; tears down on any failure with a tail of the server log. Out of scope: concurrent-apply lock, connection encryption asymmetry, `lobu pull` (v2), secrets push (v3). --- docs/plans/lobu-apply.md | 25 +- .../_lib/apply/__tests__/diff.test.ts | 103 ++++++ .../cli/src/commands/_lib/apply/client.ts | 4 +- packages/cli/src/commands/_lib/apply/diff.ts | 18 +- .../lobu/__tests__/agent-routes-apply.test.ts | 38 +++ .../owletto-backend/src/lobu/agent-routes.ts | 16 +- packages/owletto-backend/src/start-local.ts | 132 +++++++- .../src/workspace/multi-tenant.ts | 37 ++ scripts/e2e-lobu-apply.sh | 319 ++++++++++++++++++ 9 files changed, 669 insertions(+), 23 deletions(-) create mode 100755 scripts/e2e-lobu-apply.sh diff --git a/docs/plans/lobu-apply.md b/docs/plans/lobu-apply.md index 18822f3a2..7e21f1215 100644 --- a/docs/plans/lobu-apply.md +++ b/docs/plans/lobu-apply.md @@ -162,16 +162,21 @@ Pi flagged these — explicit do-not-copy list for the CLI agent: ### End-to-end (this plan's exit criterion) -After all 3 PRs merge: -1. `make build-packages` -2. Spin up local Postgres, apply all migrations -3. Boot `lobu run` configured in **DB-first mode** (host-provided stores) — this matches the cloud topology -4. Author a sample `lobu.toml` with one agent, one telegram connection, one memory entity type, one provider -5. `lobu apply --dry-run` — verify diff shows 4 creates -6. `lobu apply` — accept prompt, verify Postgres rows -7. `lobu apply --dry-run` again — should report all noops -8. Edit one connection's config, re-run `lobu apply` — verify only that connection shows update + "will restart" -9. Manually edit the connection in Postgres, re-run `lobu apply` — verify update path runs (we don't have drift detection in v1, so the change is just overwritten — document this as expected v1 behavior) +**Status: proven via `scripts/e2e-lobu-apply.sh` (see hardening PR).** + +The script: +1. Builds packages + CLI. +2. Boots `start-local.ts` against PGlite with `LOBU_LOCAL_BOOTSTRAP=true`. The bootstrap path mints a default user/org (slug `dev`)/PAT and saves the token to `${OWLETTO_DATA_DIR}/bootstrap-pat.txt`. +3. Reads the PAT, configures a CLI context pointing at the local server, and `lobu login --token `. +4. Drops a sample project at `/tmp/e2e-project/` with one agent, one telegram connection, one provider, and one entity-type yaml. +5. `lobu apply --dry-run` → asserts `+ agent`, `+ connection`, `+ entity-type` rows. +6. `lobu apply --yes` → asserts "Apply complete". +7. Re-runs `--dry-run` → asserts no `+`/`~` rows (full noop round-trip). +8. Mutates `chatId` in `lobu.toml`, re-runs apply → asserts `~ connection` + "will restart" marker. +9. Curls REST endpoints with the bootstrap PAT to verify rows landed in Postgres. +10. Cleans up the server, data dir, and project dir. + +Manual steps from the original plan (DB-first `lobu run`, postgres editing) are obsolete — the bootstrap path is the supported dev loop. ## Cross-cutting concerns diff --git a/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts b/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts index 90ec869dc..ed1940463 100644 --- a/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts +++ b/packages/cli/src/commands/_lib/apply/__tests__/diff.test.ts @@ -238,6 +238,109 @@ describe("apply diff — memory schema", () => { }); }); +describe("apply diff — empty container preservation", () => { + // Bug fix: previously canonical() collapsed [] and {} to null, which + // meant clearing a remote allowlist by setting it to [] silently + // round-tripped as a noop instead of an update. + test("clearing networkConfig.allowedDomains from non-empty to [] is an update", () => { + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + networkConfig: { allowedDomains: [] }, + }, + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + networkConfig: { allowedDomains: ["foo.com"] }, + updatedAt: 0, + }, + ], + ]), + connectionsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desired, remote); + const settingsRow = plan.rows.find((r) => r.kind === "settings"); + expect(settingsRow?.verb).toBe("update"); + if (settingsRow?.kind === "settings") { + expect(settingsRow.changedFields).toContain("networkConfig"); + } + }); + + test("[] is not equal to null (preserved as distinct values)", () => { + // When desired sets allowedDomains: [] and remote has the field + // missing entirely, the diff should still treat them as equivalent + // for the case where remote literally doesn't have the field — but + // [] vs the explicit array ["foo"] must differ. + const desiredEmpty = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + settings: { + networkConfig: { allowedDomains: [] }, + }, + }), + ]); + const remoteWithItems: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([ + [ + "triage", + { + networkConfig: { allowedDomains: ["x.com"] }, + updatedAt: 0, + }, + ], + ]), + connectionsByAgent: new Map([["triage", []]]), + }; + const plan = computeDiff(desiredEmpty, remoteWithItems); + expect(plan.counts.update).toBeGreaterThan(0); + }); + + test("{} is not equal to populated object", () => { + // empty config object vs populated config object must show as drift/update + const desired = buildState([ + buildDesiredAgent("triage", { + metadata: { agentId: "triage", name: "Triage" }, + connections: [ + { + stableId: "triage-telegram", + type: "telegram", + config: {}, + }, + ], + }), + ]); + const remote: RemoteSnapshot = { + ...emptyRemote(), + agents: [{ agentId: "triage", name: "Triage" }], + agentSettings: new Map([["triage", null]]), + connectionsByAgent: new Map([ + [ + "triage", + [ + { + id: "triage-telegram", + platform: "telegram", + config: { botToken: "abc" }, + }, + ], + ], + ]), + }; + const plan = computeDiff(desired, remote); + const connRow = plan.rows.find((r) => r.kind === "connection"); + expect(connRow?.verb).toBe("update"); + }); +}); + describe("renderSummary", () => { test("renders zero-row plan", () => { const desired = buildState([]); diff --git a/packages/cli/src/commands/_lib/apply/client.ts b/packages/cli/src/commands/_lib/apply/client.ts index 3cf80256d..94be77e59 100644 --- a/packages/cli/src/commands/_lib/apply/client.ts +++ b/packages/cli/src/commands/_lib/apply/client.ts @@ -249,7 +249,9 @@ export class ApplyClient { }): Promise { const { body } = await this.request( "POST", - `/api/${this.orgSlug}/agents/`, + // No trailing slash — Hono matches `routes.post('/', ...)` mounted at + // `/api/:orgSlug/agents` against `/api/dev/agents`, not `/api/dev/agents/`. + `/api/${this.orgSlug}/agents`, agent, [200, 201] ); diff --git a/packages/cli/src/commands/_lib/apply/diff.ts b/packages/cli/src/commands/_lib/apply/diff.ts index 2a1b2cba6..01f2ad1c8 100644 --- a/packages/cli/src/commands/_lib/apply/diff.ts +++ b/packages/cli/src/commands/_lib/apply/diff.ts @@ -79,10 +79,10 @@ export interface DiffPlan { * Stable structural equality for JSON-shaped values. Sorts object keys before * stringifying so `{a:1,b:2}` and `{b:2,a:1}` compare equal. * - * Returns false (i.e. "different") when either side is `undefined` and the - * other isn't — but treats `[]` vs `undefined` and `{}` vs `undefined` as - * **equal**, since empty containers carry no semantic state and the server - * commonly omits them. + * `undefined` and `null` both canonicalize to `"null"` so missing-on-one-side + * fields don't show as drift. Empty arrays and empty objects are preserved + * as themselves — clearing a remote allowlist by setting it to `[]` must + * produce an `update`, not a `noop`. */ function deepEqual(a: unknown, b: unknown): boolean { return canonical(a) === canonical(b); @@ -91,14 +91,12 @@ function deepEqual(a: unknown, b: unknown): boolean { function canonical(value: unknown): string { if (value === undefined || value === null) return "null"; if (Array.isArray(value)) { - if (value.length === 0) return "null"; return `[${value.map(canonical).join(",")}]`; } if (typeof value === "object") { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => a.localeCompare(b)); - if (entries.length === 0) return "null"; return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`; } return JSON.stringify(value); @@ -211,7 +209,13 @@ function diffConnection( } const changed: string[] = []; if (desired.type !== remote.platform) changed.push("type"); - if (!deepEqual(desired.config, remote.config ?? {})) changed.push("config"); + // The route handler stores `platform` inside `config` for stable-id matching, + // so a noop round-trip from GET will have an extra `platform` key the CLI + // never wrote. Strip it before diffing so an unchanged connection doesn't + // show as drift on every plan. + const remoteConfig: Record = { ...(remote.config ?? {}) }; + delete remoteConfig.platform; + if (!deepEqual(desired.config, remoteConfig)) changed.push("config"); if (changed.length === 0) { return { kind: "connection", diff --git a/packages/owletto-backend/src/lobu/__tests__/agent-routes-apply.test.ts b/packages/owletto-backend/src/lobu/__tests__/agent-routes-apply.test.ts index 2de616312..e1d93f96f 100644 --- a/packages/owletto-backend/src/lobu/__tests__/agent-routes-apply.test.ts +++ b/packages/owletto-backend/src/lobu/__tests__/agent-routes-apply.test.ts @@ -323,6 +323,44 @@ describe('PUT /agents/:agentId/connections/by-stable-id/:stableId', () => { expect(body.connection?.id).toBe(stableId); }); + test('PUT with settings-only change returns updated + willRestart', async () => { + const app = await importAgentRoutes(); + const stableId = 'host-agent-tg-settings'; + + // First create with default settings. + const create = await app.request( + `/host-agent/connections/by-stable-id/${stableId}`, + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + platform: 'telegram', + config: { chatId: 'C-1' }, + }), + } + ); + expect(create.status).toBe(201); + + // Same config, but settings change (allowFrom from undefined to ['user-1']). + const response = await app.request( + `/host-agent/connections/by-stable-id/${stableId}`, + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + platform: 'telegram', + config: { chatId: 'C-1' }, + settings: { allowFrom: ['user-1'], allowGroups: true }, + }), + } + ); + expect(response.status).toBe(200); + const body = (await response.json()) as any; + expect(body.updated).toBe(true); + expect(body.willRestart).toBe(true); + expect(body.noop).toBeUndefined(); + }); + test('PUT against an unknown agent returns 404', async () => { const app = await importAgentRoutes(); diff --git a/packages/owletto-backend/src/lobu/agent-routes.ts b/packages/owletto-backend/src/lobu/agent-routes.ts index 41705ae79..d6ed2f088 100644 --- a/packages/owletto-backend/src/lobu/agent-routes.ts +++ b/packages/owletto-backend/src/lobu/agent-routes.ts @@ -784,8 +784,14 @@ routes.put('/:agentId/connections/by-stable-id/:stableId', mcpAuth, async (c) => merged.platform = platform; const configChanged = !configsShallowEqual(merged, previousConfig); - - if (!configChanged) { + // Settings (allowFrom, allowGroups, etc.) are persisted alongside the + // connection config and are part of "did anything change?" — a + // settings-only update must trigger willRestart, not be silently noop'd. + const previousSettings = (existing.settings ?? {}) as Record; + const mergedSettings = { allowGroups: true, ...settings } as Record; + const settingsChanged = !configsShallowEqual(mergedSettings, previousSettings); + + if (!configChanged && !settingsChanged) { return c.json({ noop: true, connection: existing }, 200); } @@ -846,14 +852,16 @@ routes.put('/:agentId/connections/by-stable-id/:stableId', mcpAuth, async (c) => // Fallback path mirrors the POST handler's no-manager branch but uses // the supplied stable ID instead of a synthesized one. Platform is kept // in config (matching the manager path) so subsequent idempotent PUTs - // see a stable previousConfig. + // see a stable previousConfig. Settings default `allowGroups: true` to + // match the manager-path default — symmetric with the noop comparison + // above so a follow-up PUT with no settings field round-trips as noop. const now = Date.now(); await connectionStore.saveConnection({ id: stableId, platform, templateAgentId: agentId, config: { platform, ...config } as Record, - settings: settings as any, + settings: { allowGroups: true, ...settings } as any, metadata: {}, status: 'stopped', createdAt: now, diff --git a/packages/owletto-backend/src/start-local.ts b/packages/owletto-backend/src/start-local.ts index dd91505cf..998a70ce8 100644 --- a/packages/owletto-backend/src/start-local.ts +++ b/packages/owletto-backend/src/start-local.ts @@ -12,7 +12,7 @@ import { fork } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import { existsSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import { createRequire } from 'node:module'; import { homedir } from 'node:os'; @@ -23,6 +23,8 @@ import dotenv from 'dotenv'; dotenv.config(); +import { generatePAT, getPATPrefix, hashToken } from './auth/oauth/utils'; + import { PGlite } from '@electric-sql/pglite'; import { pg_trgm } from '@electric-sql/pglite/contrib/pg_trgm'; import { vector } from '@electric-sql/pglite/vector'; @@ -166,6 +168,18 @@ async function main() { logger.info(`Owletto running at http://${HOST}:${PORT}`); logger.info(`Data: ${DATA_DIR}`); }); + + // ─── Bootstrap PAT (dev-only) ──────────────────────────────── + // Gated behind LOBU_LOCAL_BOOTSTRAP=true; production deployments never set + // this flag, so the path is dead in cloud. Used by `scripts/e2e-lobu-apply.sh` + // to obtain a CLI-usable bearer without OAuth or admin-password. + if (isTruthyEnv('LOBU_LOCAL_BOOTSTRAP')) { + try { + await ensureBootstrapPat(dbUrl); + } catch (err) { + logger.warn({ err }, 'Bootstrap PAT setup failed'); + } + } } // ─── Migrations ────────────────────────────────────────────────── @@ -339,6 +353,122 @@ async function applyEmbeddedSchemaPatches(sql: MigrationSqlClient) { } } +// ─── Bootstrap PAT (dev-only — LOBU_LOCAL_BOOTSTRAP=true) ───────── +// +// Mints a default user, personal org (slug `dev`), member, and PAT scoped to +// both. Idempotent: if `bootstrap-pat.txt` already exists under +// OWLETTO_DATA_DIR the function is a no-op (log only). Production deployments +// never set LOBU_LOCAL_BOOTSTRAP — main() guards the call. + +const BOOTSTRAP_USER_ID = 'bootstrap-user'; +const BOOTSTRAP_USER_EMAIL = 'dev@local'; +const BOOTSTRAP_USER_NAME = 'Local Developer'; +const BOOTSTRAP_USERNAME = 'dev-local'; +const BOOTSTRAP_ORG_ID = 'org-bootstrap-dev'; +const BOOTSTRAP_ORG_SLUG = 'dev'; +const BOOTSTRAP_ORG_NAME = 'Local Dev'; +const BOOTSTRAP_MEMBER_ID = 'member-bootstrap-dev'; +const BOOTSTRAP_PAT_FILENAME = 'bootstrap-pat.txt'; + +async function ensureBootstrapPat(dbUrl: string): Promise { + const patFilePath = join(DATA_DIR, BOOTSTRAP_PAT_FILENAME); + if (existsSync(patFilePath)) { + logger.info( + { path: patFilePath, org: BOOTSTRAP_ORG_SLUG }, + 'Bootstrap PAT already provisioned (set LOBU_LOCAL_BOOTSTRAP=false to skip)' + ); + return; + } + + // Reuse the same dynamic-import shape `runMigrations` above uses so we share + // postgres' module init cost with that path on first boot. + const pg = await import('postgres'); + const sql = pg.default(dbUrl, { max: 1 }); + + try { + // Idempotent user/org/member upsert. Re-runs of the embedded schema (e.g. + // OWLETTO_DATA_DIR pre-existing without the PAT file) skip ON CONFLICT. + await sql` + INSERT INTO "user" (id, name, email, username, "emailVerified", "createdAt", "updatedAt") + VALUES ( + ${BOOTSTRAP_USER_ID}, + ${BOOTSTRAP_USER_NAME}, + ${BOOTSTRAP_USER_EMAIL}, + ${BOOTSTRAP_USERNAME}, + true, + NOW(), + NOW() + ) + ON CONFLICT (id) DO NOTHING + `; + + const metadata = JSON.stringify({ personal_org_for_user_id: BOOTSTRAP_USER_ID }); + await sql` + INSERT INTO "organization" (id, name, slug, visibility, metadata, "createdAt") + VALUES ( + ${BOOTSTRAP_ORG_ID}, + ${BOOTSTRAP_ORG_NAME}, + ${BOOTSTRAP_ORG_SLUG}, + 'private', + ${metadata}, + NOW() + ) + ON CONFLICT (id) DO NOTHING + `; + + await sql` + INSERT INTO "member" (id, "userId", "organizationId", role, "createdAt") + VALUES ( + ${BOOTSTRAP_MEMBER_ID}, + ${BOOTSTRAP_USER_ID}, + ${BOOTSTRAP_ORG_ID}, + 'owner', + NOW() + ) + ON CONFLICT (id) DO NOTHING + `; + + // Mint the PAT. Reuse the auth utils so the hash + prefix shapes stay in + // lock-step with PersonalAccessTokenService.create(). + const token = generatePAT(); + const tokenHash = hashToken(token); + const tokenPrefix = getPATPrefix(token); + + // Owner-tier scopes so admin-only tools (manage_entity_schema, etc.) work. + // The bootstrap user is the org owner — no separate consent step here, the + // user explicitly opted in by setting LOBU_LOCAL_BOOTSTRAP=true. + const bootstrapScope = 'mcp:read mcp:write mcp:admin'; + await sql` + INSERT INTO personal_access_tokens ( + token_hash, token_prefix, user_id, organization_id, + name, description, scope, expires_at + ) VALUES ( + ${tokenHash}, + ${tokenPrefix}, + ${BOOTSTRAP_USER_ID}, + ${BOOTSTRAP_ORG_ID}, + 'bootstrap', + 'LOBU_LOCAL_BOOTSTRAP — printed once on first boot', + ${bootstrapScope}, + NULL + ) + `; + + writeFileSync(patFilePath, `${token}\n`, { mode: 0o600 }); + + const url = `http://localhost:${PORT}`; + process.stdout.write(`[bootstrap PAT] ${token}\n`); + process.stdout.write(`[bootstrap PAT] org=${BOOTSTRAP_ORG_SLUG} url=${url}\n`); + process.stdout.write(`[bootstrap PAT] saved to ${patFilePath}\n`); + logger.info( + { path: patFilePath, org: BOOTSTRAP_ORG_SLUG, url }, + 'Bootstrap PAT minted (printed once)' + ); + } finally { + await sql.end(); + } +} + // ─── Embeddings (child process) ────────────────────────────────── function findFreePort(): Promise { diff --git a/packages/owletto-backend/src/workspace/multi-tenant.ts b/packages/owletto-backend/src/workspace/multi-tenant.ts index aa830b583..d03ffadf5 100644 --- a/packages/owletto-backend/src/workspace/multi-tenant.ts +++ b/packages/owletto-backend/src/workspace/multi-tenant.ts @@ -386,11 +386,48 @@ export class MultiTenantProvider implements WorkspaceProvider { ); } + // Populate `user` for PAT/OAuth-bearer paths so REST routes that read + // `c.get('user')` (e.g. POST /agents owner attribution) have a value. + // Mirrors the cli-token branch above so the two bearer flavours behave + // identically downstream. + let bearerUser: { id: string; email: string; name: string; emailVerified: boolean } | null = + null; + try { + const userRows = await simpleQuery(sql` + SELECT id, email, name, "emailVerified" + FROM "user" + WHERE id = ${authInfo.userId} + LIMIT 1 + `); + if (userRows.length > 0) { + const row = userRows[0] as { + id: string; + email: string; + name: string; + emailVerified: boolean | string | number | null; + }; + bearerUser = { + id: row.id, + email: row.email ?? '', + name: row.name ?? '', + emailVerified: + typeof row.emailVerified === 'boolean' + ? row.emailVerified + : row.emailVerified === 't' || + row.emailVerified === 'true' || + row.emailVerified === 1, + }; + } + } catch { + bearerUser = null; + } + await setContextAndContinue({ mcpAuthInfo: authInfo, mcpIsAuthenticated: true, organizationId: effectiveOrgId, memberRole: role, + user: bearerUser, }); return undefined; } diff --git a/scripts/e2e-lobu-apply.sh b/scripts/e2e-lobu-apply.sh new file mode 100755 index 000000000..c83c14f13 --- /dev/null +++ b/scripts/e2e-lobu-apply.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# +# End-to-end harness for `lobu apply` v1. +# +# Boots `start-local.ts` with LOBU_LOCAL_BOOTSTRAP=true so we get an +# out-of-band PAT, drives the CLI through create → noop → update → drift, +# and asserts the round-trip against PGlite. +# +# Idempotent: cleans up its own server, data dir, and project dir on exit. + +set -euo pipefail + +# ─── locations ───────────────────────────────────────────────────────── +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA_DIR="/tmp/e2e-data" +PROJECT_DIR="/tmp/e2e-project" +SERVER_LOG="/tmp/e2e-server.log" +PORT=8801 +SERVER_URL="http://localhost:${PORT}" +API_URL="${SERVER_URL}" +ORG_SLUG="dev" +SERVER_PID="" + +cleanup() { + local exit_code=$? + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then + echo "==> cleanup: killing server pid ${SERVER_PID}" + kill "${SERVER_PID}" 2>/dev/null || true + # Give it a moment, then SIGKILL if still alive. + for _ in 1 2 3 4 5; do + kill -0 "${SERVER_PID}" 2>/dev/null || break + sleep 0.5 + done + kill -9 "${SERVER_PID}" 2>/dev/null || true + fi + rm -rf "${DATA_DIR}" "${PROJECT_DIR}" 2>/dev/null || true + if [[ ${exit_code} -ne 0 ]]; then + echo "==> e2e FAILED (exit ${exit_code})" + if [[ -f "${SERVER_LOG}" ]]; then + echo "── server log (last 80 lines) ───────────────────────────────" + tail -n 80 "${SERVER_LOG}" || true + echo "─────────────────────────────────────────────────────────────" + fi + fi + exit "${exit_code}" +} +trap cleanup EXIT INT TERM + +# ─── pre-flight ──────────────────────────────────────────────────────── +rm -rf "${DATA_DIR}" "${PROJECT_DIR}" "${SERVER_LOG}" +# Stale local build that the dev workflow occasionally produces. +rm -rf "${REPO_ROOT}/packages/owletto-cli/runtime" 2>/dev/null || true + +# ─── 1. build ────────────────────────────────────────────────────────── +echo "==> step 1: build packages + CLI" +cd "${REPO_ROOT}" +make build-packages >/dev/null +(cd packages/cli && bun run build) >/dev/null + +CLI_BIN="${REPO_ROOT}/packages/cli/bin/lobu.js" +if [[ ! -f "${CLI_BIN}" ]]; then + echo "CLI binary not found at ${CLI_BIN}" >&2 + exit 1 +fi +LOBU="node ${CLI_BIN}" + +# ─── 2. start server ─────────────────────────────────────────────────── +echo "==> step 2: start start-local.ts on :${PORT} (LOBU_LOCAL_BOOTSTRAP=true)" + +# Unset DATABASE_URL — start-local.ts boots PGlite and writes its own +# socket URL into process.env. A pre-set DATABASE_URL would race with the +# socket bind. +env \ + -u DATABASE_URL \ + LOBU_LOCAL_BOOTSTRAP=true \ + OWLETTO_DATA_DIR="${DATA_DIR}" \ + PORT="${PORT}" \ + HOST=127.0.0.1 \ + PG_SOCKET_PORT=0 \ + bun run "${REPO_ROOT}/packages/owletto-backend/src/start-local.ts" \ + >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! + +echo " server pid=${SERVER_PID} log=${SERVER_LOG}" + +# Wait for /health. +for i in $(seq 1 60); do + if curl -sf "${SERVER_URL}/health" >/dev/null 2>&1; then + echo " server up after ${i}s" + break + fi + if ! kill -0 "${SERVER_PID}" 2>/dev/null; then + echo "server died before becoming ready" >&2 + exit 1 + fi + sleep 1 + if [[ $i -eq 60 ]]; then + echo "server did not become ready within 60s" >&2 + exit 1 + fi +done + +# ─── 3. read bootstrap PAT ───────────────────────────────────────────── +echo "==> step 3: read bootstrap PAT" + +# Wait briefly for the bootstrap path (runs after listen) to write the file. +PAT_FILE="${DATA_DIR}/bootstrap-pat.txt" +for i in $(seq 1 20); do + if [[ -s "${PAT_FILE}" ]]; then break; fi + sleep 0.5 +done + +if [[ ! -s "${PAT_FILE}" ]]; then + echo "bootstrap PAT not written to ${PAT_FILE}" >&2 + exit 1 +fi +PAT="$(cat "${PAT_FILE}")" +PAT="${PAT//$'\n'/}" +if [[ -z "${PAT}" || "${PAT}" != owl_pat_* ]]; then + echo "bootstrap PAT looks malformed: ${PAT}" >&2 + exit 1 +fi +echo " PAT prefix: ${PAT:0:16}..." + +# ─── 4. write sample project ─────────────────────────────────────────── +echo "==> step 4: write sample lobu.toml + agent dir + models" +mkdir -p "${PROJECT_DIR}/agents/triage" "${PROJECT_DIR}/models" + +cat > "${PROJECT_DIR}/lobu.toml" <<'TOML' +[agents.triage] +name = "Triage" +description = "Test triage agent for e2e harness" +dir = "./agents/triage" + +[[agents.triage.providers]] +id = "anthropic" +model = "claude/sonnet-4-5" +key = "$ANTHROPIC_API_KEY" + +[[agents.triage.connections]] +type = "telegram" +config = { botToken = "$TELEGRAM_BOT_TOKEN", chatId = "12345" } + +[memory.owletto] +enabled = true +org = "dev" +name = "Local Dev" +description = "Local dev memory" +models = "./models" +TOML + +cat > "${PROJECT_DIR}/agents/triage/IDENTITY.md" <<'MD' +# Identity + +You are a triage agent under e2e test. +MD + +cat > "${PROJECT_DIR}/agents/triage/SOUL.md" <<'MD' +# Instructions + +- Be concise. +MD + +cat > "${PROJECT_DIR}/agents/triage/USER.md" <<'MD' +# User Context + +- e2e harness driver +MD + +cat > "${PROJECT_DIR}/models/person.yaml" <<'YAML' +version: 1 +type: entity +slug: person +name: Person +description: Test person entity for e2e +metadata_schema: + type: object + properties: + full_name: + type: string +YAML + +# ─── 5. configure CLI context + login ────────────────────────────────── +echo "==> step 5: configure CLI context + login with PAT" + +# CLI config dir scoped to the test so we don't clobber the user's profile. +CLI_HOME="$(mktemp -d /tmp/e2e-clihome.XXXX)" +export HOME="${CLI_HOME}" +mkdir -p "${HOME}/.config/lobu" + +# Add a context pointing at our local server, then mark it current. +${LOBU} context add e2e --api-url "${API_URL}" >/dev/null +${LOBU} context use e2e >/dev/null + +# `lobu login --token --context e2e` validates against /auth/whoami; +# our local server only mounts that path under /api/v1/auth, so the validate +# step gets a 404 → "unverified" → token is saved with a warning. That's the +# expected dev-loop shape. +LOGIN_OUT="$(${LOBU} login --token "${PAT}" --context e2e --force 2>&1 || true)" +echo "${LOGIN_OUT}" | sed 's/^/ /' + +# Confirm credentials landed. +if ! grep -q "${PAT}" "${HOME}/.config/lobu/credentials.json" 2>/dev/null; then + echo "credentials.json did not get the PAT" >&2 + echo " HOME=${HOME}" + ls -la "${HOME}/.config/lobu/" 2>&1 | sed 's/^/ /' || true + cat "${HOME}/.config/lobu/credentials.json" 2>&1 | sed 's/^/ /' || true + exit 1 +fi + +# Apply also looks at LOBU_API_TOKEN as a fast path; rely on it to dodge +# any context-resolution surprises in CI. +export LOBU_API_TOKEN="${PAT}" +export LOBU_CONTEXT=e2e +export LOBU_MEMORY_URL="${SERVER_URL}/mcp" + +# ─── 6. fake secrets ─────────────────────────────────────────────────── +export TELEGRAM_BOT_TOKEN="fake-tg-token-for-e2e" +export ANTHROPIC_API_KEY="fake-anth-key-for-e2e" + +# ─── 7. dry-run ──────────────────────────────────────────────────────── +echo "==> step 7: lobu apply --dry-run (expect creates)" +cd "${PROJECT_DIR}" +DRY_OUT="$(${LOBU} apply --dry-run --org "${ORG_SLUG}" --url "${SERVER_URL}" 2>&1)" +echo "${DRY_OUT}" | sed 's/^/ /' + +# Assertions on dry-run output: at least one `+` per resource kind. +echo "${DRY_OUT}" | grep -E "\+\s+agent\s+triage" >/dev/null || { + echo "dry-run missing '+ agent triage' line" >&2 + exit 1 +} +echo "${DRY_OUT}" | grep -E "\+\s+connection" >/dev/null || { + echo "dry-run missing '+ connection' line" >&2 + exit 1 +} +echo "${DRY_OUT}" | grep -E "\+\s+entity[-_]type" >/dev/null || { + echo "dry-run missing '+ entity-type' line" >&2 + exit 1 +} + +# ─── 8. apply --yes ──────────────────────────────────────────────────── +echo "==> step 8: lobu apply --yes (expect success)" +APPLY_OUT="$(${LOBU} apply --yes --org "${ORG_SLUG}" --url "${SERVER_URL}" 2>&1)" +echo "${APPLY_OUT}" | sed 's/^/ /' +echo "${APPLY_OUT}" | grep -E "Apply complete" >/dev/null || { + echo "apply did not report completion" >&2 + exit 1 +} + +# ─── 9. dry-run again — all noop ─────────────────────────────────────── +echo "==> step 9: lobu apply --dry-run (expect noop)" +NOOP_OUT="$(${LOBU} apply --dry-run --org "${ORG_SLUG}" --url "${SERVER_URL}" 2>&1)" +echo "${NOOP_OUT}" | sed 's/^/ /' +if echo "${NOOP_OUT}" | grep -E "^\s*\+\s+(agent|connection|entity)" >/dev/null; then + echo "second dry-run still shows + creates — not idempotent" >&2 + exit 1 +fi +if echo "${NOOP_OUT}" | grep -E "^\s*~\s+(agent|connection|entity)" >/dev/null; then + echo "second dry-run shows ~ updates — drift detected unexpectedly" >&2 + exit 1 +fi + +# ─── 10. mutate connection chatId, expect ~ update ───────────────────── +echo "==> step 10: edit connection chatId, expect connection update + restart" + +# Same shape, different chatId. +sed -i.bak \ + -e 's/chatId = "12345"/chatId = "67890"/' \ + "${PROJECT_DIR}/lobu.toml" +rm -f "${PROJECT_DIR}/lobu.toml.bak" + +UPDATE_OUT="$(${LOBU} apply --dry-run --org "${ORG_SLUG}" --url "${SERVER_URL}" 2>&1)" +echo "${UPDATE_OUT}" | sed 's/^/ /' +echo "${UPDATE_OUT}" | grep -E "~\s+connection" >/dev/null || { + echo "expected '~ connection' line after edit" >&2 + exit 1 +} +echo "${UPDATE_OUT}" | grep -E "will restart" >/dev/null || { + echo "expected 'will restart' marker after connection config change" >&2 + exit 1 +} + +APPLY_UPDATE_OUT="$(${LOBU} apply --yes --org "${ORG_SLUG}" --url "${SERVER_URL}" 2>&1)" +echo "${APPLY_UPDATE_OUT}" | sed 's/^/ /' +echo "${APPLY_UPDATE_OUT}" | grep -E "Apply complete" >/dev/null || { + echo "update apply did not complete" >&2 + exit 1 +} + +# ─── 11. verify rows landed in PG ────────────────────────────────────── +echo "==> step 11: verify rows in PG via REST" + +AGENTS_JSON="$(curl -sf -H "Authorization: Bearer ${PAT}" "${SERVER_URL}/api/${ORG_SLUG}/agents")" +echo "${AGENTS_JSON}" | grep -q '"agentId":"triage"' || { + echo "triage agent not found via /api/${ORG_SLUG}/agents" >&2 + echo "${AGENTS_JSON}" + exit 1 +} + +CONNS_JSON="$(curl -sf -H "Authorization: Bearer ${PAT}" "${SERVER_URL}/api/${ORG_SLUG}/agents/triage/connections")" +echo "${CONNS_JSON}" | grep -q '"platform":"telegram"' || { + echo "telegram connection not found" >&2 + echo "${CONNS_JSON}" + exit 1 +} + +ENTITY_JSON="$(curl -sf -X POST \ + -H "Authorization: Bearer ${PAT}" \ + -H "Content-Type: application/json" \ + -d '{"schema_type":"entity_type","action":"list"}' \ + "${SERVER_URL}/api/${ORG_SLUG}/manage_entity_schema")" +echo "${ENTITY_JSON}" | grep -q '"slug":"person"' || { + echo "person entity_type not found" >&2 + echo "${ENTITY_JSON}" + exit 1 +} + +# ─── 12. cleanup handled by trap ─────────────────────────────────────── +echo "==> step 12: e2e PASSED"