diff --git a/backend/__tests__/service/verify.firstReply.test.js b/backend/__tests__/service/verify.firstReply.test.js new file mode 100644 index 0000000000..753c3a15fb --- /dev/null +++ b/backend/__tests__/service/verify.firstReply.test.js @@ -0,0 +1,185 @@ +/** TASK-527: extends TASK-514's production createApp / real-HTTP harness. + * Real Mongo 7 + Postgres in Tier 1. Only the external model provider is a + * deterministic loopback HTTP fixture; auth, install, queue and writers are real. + * The BYO fixture is an external CAP client, not a mocked event service. + */ +const http = require('http'); +const request = require('supertest'); +const { randomUUID } = require('crypto'); +const { setupMongoDb, closeMongoDb, setupPgDb, closePgDb, waitFor } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const AgentRun = require('../../models/AgentRun'); +const AgentEvent = require('../../models/AgentEvent'); +const { AgentInstallation } = require('../../models/AgentRegistry'); + +// Keep cold TypeScript compilation outside the asynchronous database hook. +// TASK-514's factory import starts no database connection or background worker. +const createApp = process.env.INTEGRATION_TEST === 'true' ? require('../../server').createApp : null; +const tier1 = process.env.INTEGRATION_TEST === 'true' ? describe : describe.skip; +const LIMIT_MS = 15000; +const pause = () => new Promise((resolve) => setTimeout(resolve, 150)); +const auth = (token) => ({ Authorization: `Bearer ${token}` }); +const messages = (response) => Array.isArray(response.body) ? response.body : response.body.messages; + +tier1('commonly verify first-agent-reply — production HTTP routes', () => { + let app; + let provider; + let providerRequests; + const environment = ['LITELLM_BASE_URL', 'LITELLM_MASTER_KEY', 'REGISTRATION_INVITE_ONLY', + 'SMTP2GO_API_KEY', 'INSTANCE_ALLOWED_EMAILS']; + let previous; + + beforeAll(async () => { + previous = Object.fromEntries(environment.map((key) => [key, process.env[key]])); + delete process.env.SMTP2GO_API_KEY; + delete process.env.INSTANCE_ALLOWED_EMAILS; + process.env.REGISTRATION_INVITE_ONLY = 'false'; + providerRequests = []; + provider = http.createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString() || '{}'); + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/chat/completions') { + providerRequests.push(body); + const posted = body.messages.some((message) => message.role === 'tool'); + const message = posted ? { role: 'assistant', content: 'NO_REPLY' } : { + role: 'assistant', content: null, tool_calls: [{ id: 'verify527-post', type: 'function', + function: { name: 'commonly_post_message', arguments: JSON.stringify({ content: 'verify527-hosted-reply' }) } }], + }; + res.end(JSON.stringify({ id: 'verify527-completion', choices: [{ index: 0, + message, finish_reason: posted ? 'stop' : 'tool_calls' }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } })); + } else if (req.url === '/guardrails/apply_guardrail') res.end('{}'); + else { res.statusCode = 404; res.end('{}'); } + }); + await new Promise((resolve) => provider.listen(0, '127.0.0.1', resolve)); + process.env.LITELLM_BASE_URL = `http://127.0.0.1:${provider.address().port}`; + process.env.LITELLM_MASTER_KEY = 'verify527-local-only'; + await setupMongoDb(); + await setupPgDb(); + app = createApp(); + }, 120000); + + afterAll(async () => { + if (app) await new Promise((resolve) => app.locals.socketServer.close(resolve)); + if (provider) await new Promise((resolve) => provider.close(resolve)); + const { pool } = require('../../config/db-pg'); + if (pool) await pool.end(); + await closePgDb(); + await closeMongoDb(); + for (const key of environment) { + if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; + } + }); + + async function signup() { + const name = `verify527-${randomUUID().slice(0, 8)}`; + const email = `${name}@example.test`; + await request(app).post('/api/auth/register').send({ username: name, email, password: 'Password527!' }).expect(201); + const login = await request(app).post('/api/auth/login').send({ email, password: 'Password527!' }).expect(200); + const token = login.body.token; + expect(token).toBeTruthy(); + const userId = login.body.user.id; + let pod; + await waitFor(async () => { + pod = await Pod.findOne({ createdBy: userId, name: 'My Workspace' }).lean(); + if (!pod) return false; + const install = await AgentInstallation.findOne({ podId: pod._id, agentName: 'scout', status: 'active' }).lean(); + return install && await User.exists({ isBot: true, 'botMetadata.agentName': 'scout', + 'botMetadata.instanceId': install.instanceId }); + }, { timeout: LIMIT_MS }); + // Decoy fixture has the same owner, but no install or first-contact wake. + const decoy = await Pod.create({ name: `${name}-decoy`, type: 'chat', + joinPolicy: 'invite-only', createdBy: userId, members: [userId] }); + const decoyId = String(decoy._id); + return { token, userId, podId: String(pod._id), decoyId }; + } + + async function awaitReply(person, expected, agentId, deadline = Date.now() + LIMIT_MS) { + while (Date.now() < deadline) { + const target = await request(app).get(`/api/messages/${person.podId}`).set(auth(person.token)).timeout({ deadline: 5000 }).expect(200); + const decoy = await request(app).get(`/api/messages/${person.decoyId}`).set(auth(person.token)).timeout({ deadline: 5000 }).expect(200); + for (const row of [...messages(target), ...messages(decoy)]) { + expect(String(row.content || '')).not.toMatch(/NO_REPLY|HEARTBEAT_OK/); + } + expect(messages(decoy).some((row) => row.content === expected)).toBe(false); + const reply = messages(target).find((row) => row.content === expected); + if (reply) { + expect(Date.now()).toBeLessThanOrEqual(deadline); + expect(String(reply.userId?._id || reply.user_id || reply.userId)).toBe(String(agentId)); + expect(String(reply.pod_id || reply.podId)).toBe(person.podId); + return reply; + } + await pause(); + } + throw new Error(`First agent reply did not arrive in target pod within ${LIMIT_MS}ms`); + } + + test('new signup gets a hosted Scout reply to its first human message', async () => { + const person = await signup(); + const scout = await AgentInstallation.findOne({ podId: person.podId, agentName: 'scout' }).lean(); + const agent = await User.findOne({ isBot: true, 'botMetadata.agentName': 'scout', + 'botMetadata.instanceId': scout.instanceId }).lean(); + expect(agent).toBeTruthy(); + const marker = `verify527-human-${randomUUID()}`; + const deadline = Date.now() + LIMIT_MS; + await request(app).post(`/api/messages/${person.podId}`).set(auth(person.token)) + .send({ content: marker }).expect(200); + await awaitReply(person, 'verify527-hosted-reply', agent._id, deadline); + await waitFor(() => AgentRun.exists({ podId: person.podId, agentName: 'scout', status: 'succeeded' }), { timeout: LIMIT_MS }); + // Re-read after the final NO_REPLY provider response has settled. + await awaitReply(person, 'verify527-hosted-reply', agent._id); + expect(providerRequests.some((body) => JSON.stringify(body.messages).includes(marker))).toBe(true); + }, 45000); + + test('new signup installs BYO, receives its wake over CAP and gets its reply in the same pod', async () => { + const person = await signup(); + const name = `verify527-byo-${randomUUID().slice(0, 8)}`; + await request(app).post('/api/registry/install').set(auth(person.token)).send({ agentName: name, + podId: person.podId, config: { runtime: { runtimeType: 'wrapper', host: 'byo' }, + wakeOnMessage: { enabled: true, humanOnly: true } }, scopes: ['context:read', 'messages:read', 'messages:write', 'events:read', 'events:ack'] }).expect(200); + const issued = await request(app).post(`/api/registry/pods/${person.podId}/agents/${name}/runtime-tokens`) + .set(auth(person.token)).send({}).expect(200); + const token = issued.body.token; + expect(token).toMatch(/^cm_agent_/); + const marker = `verify527-human-${randomUUID()}`; + const deadline = Date.now() + LIMIT_MS; + await request(app).post(`/api/messages/${person.podId}`).set(auth(person.token)) + .send({ content: `@${name} ${marker}` }).expect(200); + let event; + while (!event && Date.now() < deadline) { + const inbox = await request(app).get('/api/agents/runtime/events').set(auth(token)).expect(200); + event = inbox.body.events.find((item) => JSON.stringify(item.payload).includes(marker)); + if (!event) await pause(); + } + expect(event).toBeTruthy(); + expect(String(event.podId)).toBe(person.podId); + const fallback = 'verify527-byo-reply'; + let sent = false; + while (!sent && Date.now() < deadline) { + const read = await request(app).get(`/api/agents/runtime/pods/${person.podId}/messages`).set(auth(token)).expect(200); + expect(messages(read).some((row) => String(row.content).includes(marker))).toBe(true); + const posted = await request(app).post(`/api/agents/runtime/pods/${person.podId}/messages`) + .set(auth(token)).send({ content: fallback }); + if (posted.status === 409 && posted.body.code === 'unseen_messages') { + await pause(); // Honour the fresh read after a concurrent Scout post. + } else { + expect({ status: posted.status, body: posted.body }).toMatchObject({ status: 200, body: { success: true } }); + sent = true; + } + } + expect(sent).toBe(true); + const agent = await User.findOne({ isBot: true, 'botMetadata.agentName': name }).lean(); + await awaitReply(person, fallback, agent._id, deadline); + await request(app).post(`/api/agents/runtime/events/${event.id || event._id}/ack`) + .set(auth(token)).send({ deliveryId: event.deliveryId, result: { outcome: 'replied' } }).expect(200); + expect(await AgentEvent.findById(event.id || event._id).lean()).toMatchObject({ status: 'acked' }); + // Exercise sentinel suppression via the real HTTP writer as well. It must + // never create a user-visible row, including when a real BYO emits silence. + await request(app).post(`/api/agents/runtime/pods/${person.podId}/messages`) + .set(auth(token)).send({ content: 'NO_REPLY' }).expect(200); + await awaitReply(person, fallback, agent._id); + }, 45000); +}); diff --git a/backend/__tests__/service/verify.fixtureReset.test.js b/backend/__tests__/service/verify.fixtureReset.test.js new file mode 100644 index 0000000000..2173bd9b87 --- /dev/null +++ b/backend/__tests__/service/verify.fixtureReset.test.js @@ -0,0 +1,34 @@ +/** Claims have no message FK: a warm verification database must clear them + * before RESTART IDENTITY gives a new conversation an old message ID. + */ +const { setupPgDb, clearPgDb, closePgDb } = require('../utils/testUtils'); +const MessageClaimService = require('../../services/messageClaimService'); + +const tier1 = process.env.INTEGRATION_TEST === 'true' ? describe : describe.skip; +tier1('verification fixture claim isolation — real PostgreSQL', () => { + let pg; + beforeAll(async () => { pg = await setupPgDb(); }); + afterEach(async () => { await pg.query('TRUNCATE TABLE message_claims'); }); + afterAll(async () => { + await closePgDb(); + await require('../../config/db-pg').pool.end(); + }); + + test.each(['clear', 'setup'])('%s reset admits a reused message ID after a completed claim', async (reset) => { + const old = { messageId: '2', podId: 'old-pod', agentName: 'old-seat' }; + expect((await MessageClaimService.claim(old)).claimed).toBe(true); + // Seed persisted state from a prior suite, including legacy tombstones. + await pg.query("UPDATE message_claims SET state = 'completed', expires_at = NOW() + interval '1 hour' WHERE message_id = '2'"); + // Control: the tombstone really prevents the fresh seat from answering. + const fresh = { messageId: '2', podId: 'new-pod', agentName: 'scout' }; + expect((await MessageClaimService.claim(fresh)).claimed).toBe(false); + if (reset === 'clear') await clearPgDb(); + else { + await closePgDb(); + pg = await setupPgDb(); + } + expect((await pg.query('SELECT count(*)::int AS n FROM message_claims')).rows[0].n).toBe(0); + expect((await MessageClaimService.claim(fresh)).claimed).toBe(true); + await clearPgDb(); + }); +}); diff --git a/backend/__tests__/service/verify.wave1.test.js b/backend/__tests__/service/verify.wave1.test.js new file mode 100644 index 0000000000..37ee4a86d2 --- /dev/null +++ b/backend/__tests__/service/verify.wave1.test.js @@ -0,0 +1,206 @@ +/** Wave 1: real HTTP mount table, real auth, Mongo and PostgreSQL. + * Only the external OAuth provider is stubbed. No model/middleware/service mocks. + * These scenarios deliberately run only in the Tier 1 service job. + */ +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const axios = require('axios'); +const mongoose = require('mongoose'); +const { randomUUID } = require('crypto'); +const { setupMongoDb, closeMongoDb, setupPgDb, closePgDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const RoomGrant = require('../../models/RoomGrant'); +const ApprovalAction = require('../../models/ApprovalAction'); +const OAuthLoginState = require('../../models/OAuthLoginState'); +const AgentEvent = require('../../models/AgentEvent'); +const File = require('../../models/File'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const AgentIdentityService = require('../../services/agentIdentityService'); +const { hash } = require('../../utils/secret'); + +const tier1 = process.env.INTEGRATION_TEST === 'true' ? describe : describe.skip; +tier1('commonly verify wave 1 — production HTTP routes', () => { + let app; + let pg; + let owner; + let other; + let agent; + let installation; + let pod; + let ownerToken; + let otherToken; + const agentToken = 'cm_agent_verify-wave1-seat'; + const human = (token) => ({ Authorization: `Bearer ${token}` }); + const seat = () => human(agentToken); + + beforeAll(async () => { + // Importing the factory must not connect Mongo at module scope. + expect(mongoose.connection.readyState).toBe(0); + const { createApp } = require('../../server'); + expect(() => createApp({ startServices: true })).toThrow('Service startup is singleton-only'); + expect(mongoose.connection.readyState).toBe(0); + await setupMongoDb(); + pg = await setupPgDb(); + app = createApp(); + owner = await User.create({ username: 'verify-owner', email: 'verify-owner@test.com', password: 'password123' }); + other = await User.create({ username: 'verify-other', email: 'verify-other@test.com', password: 'password123' }); + ownerToken = jwt.sign({ id: String(owner._id) }, process.env.JWT_SECRET); + otherToken = jwt.sign({ id: String(other._id) }, process.env.JWT_SECRET); + agent = await User.create({ + username: AgentIdentityService.buildAgentUsername('openclaw', 'verify-wave1'), + email: 'verify-seat@test.com', password: 'password123', isBot: true, botType: 'agent', + botMetadata: { agentName: 'openclaw', instanceId: 'verify-wave1' }, + agentRuntimeTokens: [{ tokenHash: hash(agentToken), lastUsedAt: new Date() }], + }); + pod = await Pod.create({ name: 'Verify wave 1', type: 'team', publicRead: false, joinPolicy: 'invite-only', createdBy: owner._id, members: [owner._id, other._id, agent._id] }); + installation = await AgentInstallation.create({ + agentName: 'openclaw', instanceId: 'verify-wave1', podId: pod._id, + installedBy: owner._id, version: '1.0.0', status: 'active', + config: { runtime: { runtimeType: 'native' }, tools: ['computer_request_takeover'] }, + }); + for (const user of [owner, other, agent]) { + await pg.query('INSERT INTO users (_id, username, is_bot) VALUES ($1,$2,$3)', [String(user._id), user.username, user.isBot]); + } + await pg.query('INSERT INTO pods (id,name,type,created_by) VALUES ($1,$2,$3,$4)', [String(pod._id), pod.name, pod.type, String(owner._id)]); + await pg.query('INSERT INTO messages (pod_id,user_id,content) VALUES ($1,$2,$3)', [String(pod._id), String(owner._id), 'wave1 durable chat witness']); + }); + + afterEach(() => jest.restoreAllMocks()); + afterAll(async () => { + if (app) await new Promise((resolve) => app.locals.socketServer.close(resolve)); + const { pool } = require('../../config/db-pg'); + if (pool) await pool.end(); + await closePgDb(); + await closeMongoDb(); + }); + + test('attenuation refuses wider tools and persists a strict subset', async () => { + const grantId = randomUUID(); + await RoomGrant.create({ grantId, connectionId: 'verify-connection', installationId: String(installation._id), + target: { kind: 'pod', id: String(pod._id) }, audience: [String(agent._id)], + tools: ['read', 'search'], writeMode: 'read', brokerId: 'verify-broker', expiresAt: new Date(Date.now() + 3600000) }); + const url = `/api/grants/${grantId}/attenuate`; + const widened = await request(app).post(url).set(seat()).send({ tools: ['write'] }); + expect({ status: widened.status, error: widened.body.error }).toEqual({ status: 400, error: 'grant_not_attenuated' }); + expect(await RoomGrant.countDocuments({ parentGrantId: grantId })).toBe(0); + const narrowed = await request(app).post(url).set(seat()).send({ tools: ['read'] }).expect(201); + const stored = await RoomGrant.findOne({ grantId: narrowed.body.grantId }).lean(); + expect(stored.tools).toEqual(['read']); + expect(stored.parentGrantId).toBe(grantId); + expect(stored.audience).toEqual([String(agent._id)]); + }); + + test('approval resolution belongs to its owner, even when another human is a pod member', async () => { + const action = await ApprovalAction.create({ podId: pod._id, ownerUserId: owner._id, + agentName: 'openclaw', instanceId: 'verify-wave1', actionType: 'create_pod', + params: { name: 'Must not execute' }, summary: 'Verify owner decision', expiresAt: new Date(Date.now() + 60000) }); + const url = `/api/approvals/${action._id}/resolve`; + await request(app).post(url).set(human(otherToken)).send({ decision: 'approved' }).expect(403); + expect((await ApprovalAction.findById(action._id)).status).toBe('flagged'); + await request(app).post(url).set(human(ownerToken)).send({ decision: 'declined' }).expect(200); + const stored = await ApprovalAction.findById(action._id); + expect(stored.decision).toBe('declined'); + expect(String(stored.resolvedBy)).toBe(String(owner._id)); + expect(await Pod.countDocuments({ name: 'Must not execute' })).toBe(0); + }); + + test('OAuth callback claims state once; exchange code creates one usable human session', async () => { + process.env.GITHUB_OAUTH_CLIENT_ID = 'verify-client'; + process.env.GITHUB_OAUTH_CLIENT_SECRET = 'verify-secret'; + owner.authProviders = [{ provider: 'github', providerId: '514', email: owner.email }]; + await owner.save(); + const state = await OAuthLoginState.create({ provider: 'github', state: randomUUID(), expiresAt: new Date(Date.now() + 60000) }); + const post = jest.spyOn(axios, 'post').mockResolvedValue({ data: { access_token: 'upstream-test-only' } }); + jest.spyOn(axios, 'get').mockImplementation(async (url) => { + if (url === 'https://api.github.com/user') return { data: { id: 514, login: owner.username } }; + if (url === 'https://api.github.com/user/emails') return { data: [{ email: owner.email, verified: true, primary: true }] }; + throw new Error('Unexpected upstream URL'); + }); + const callback = `/api/auth/oauth/github/callback?state=${state.state}&code=provider-code`; + const first = await request(app).get(callback).expect(302); + const code = new URL(first.headers.location).searchParams.get('code'); + expect(code).toBeTruthy(); + expect(first.headers.location).not.toContain('token='); + const replay = await request(app).get(callback).expect(302); + expect(replay.headers.location).toContain('state_invalid'); + expect(post).toHaveBeenCalledTimes(1); + const exchanged = await request(app).post('/api/auth/oauth/exchange').send({ code }).expect(200); + expect(jwt.verify(exchanged.body.token, process.env.JWT_SECRET).id).toBe(String(owner._id)); + const session = await request(app).get('/api/auth/user').set(human(exchanged.body.token)).expect(200); + expect(String(session.body._id)).toBe(String(owner._id)); + const landing = await request(app).get('/api/pods').set(human(exchanged.body.token)).expect(200); + expect(landing.body.map((entry) => String(entry._id))).toContain(String(pod._id)); + await request(app).post('/api/auth/oauth/exchange').send({ code }).expect(400); + expect((await OAuthLoginState.findById(state._id)).exchangedAt).toBeTruthy(); + delete process.env.GITHUB_OAUTH_CLIENT_ID; + delete process.env.GITHUB_OAUTH_CLIENT_SECRET; + }); + + test('agent upload is bound to the URL pod and stores actual bytes', async () => { + const uploaded = await request(app).post(`/api/agents/runtime/pods/${pod._id}/uploads`).set(seat()) + .field('podId', String(other._id)).attach('file', Buffer.from('wave1 upload bytes'), 'verify.txt').expect(200); + const stored = await File.findOne({ fileName: uploaded.body.fileName }).lean(); + expect(String(stored.podId)).toBe(String(pod._id)); + expect(String(stored.uploadedBy)).toBe(String(agent._id)); + const { getObjectStore } = require('../../services/objectStore'); + const bytes = await getObjectStore().get(uploaded.body.fileName); + expect(bytes).toBeTruthy(); + const chunks = []; + for await (const chunk of bytes.stream) chunks.push(Buffer.from(chunk)); + expect(Buffer.concat(chunks).toString()).toBe('wave1 upload bytes'); + const refused = await Pod.create({ name: 'Other room', type: 'team', createdBy: other._id, members: [other._id] }); + await request(app).post(`/api/agents/runtime/pods/${refused._id}/uploads`).set(seat()) + .attach('file', Buffer.from('must not persist'), 'refused.txt').expect(403); + expect(await File.countDocuments({ originalName: 'refused.txt' })).toBe(0); + }); + + test('computer press gates owner and computer capability before attempting a browser action', async () => { + const keys = ['AGENT_COMPUTER_ENABLED', 'AGENT_COMPUTER_STEEL_API_URL_TEMPLATE', 'AGENT_COMPUTER_STEEL_CDP_URL_TEMPLATE', 'AGENT_COMPUTER_WAKER_URL', 'AGENT_COMPUTER_WAKER_TOKEN']; + const prior = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + try { + Object.assign(process.env, { AGENT_COMPUTER_ENABLED: 'true', AGENT_COMPUTER_STEEL_API_URL_TEMPLATE: 'http://127.0.0.1:1', AGENT_COMPUTER_STEEL_CDP_URL_TEMPLATE: 'http://127.0.0.1:1', AGENT_COMPUTER_WAKER_URL: 'http://127.0.0.1:1', AGENT_COMPUTER_WAKER_TOKEN: 'verify-only' }); + const url = `/api/agent-computer/${installation._id}/press`; + const otherPress = await request(app).post(url).set(human(otherToken)).send({ key: 'Enter', frameRevision: 'stale' }).expect(403); + expect(otherPress.body.code).toBe('not_owner'); + await AgentInstallation.updateOne({ _id: installation._id }, { $set: { 'config.tools': [] } }); + const ownerPress = await request(app).post(url).set(human(ownerToken)).send({ key: 'Enter', frameRevision: 'stale' }).expect(403); + expect(ownerPress.body.code).toBe('computer_not_granted'); + } finally { for (const key of keys) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; } } + }); + + test('CAP context and message reads return durable PG content and exclude another pod', async () => { + const foreign = await Pod.create({ name: 'Foreign content room', type: 'team', publicRead: false, joinPolicy: 'invite-only', createdBy: other._id, members: [other._id] }); + await pg.query('INSERT INTO pods (id,name,type,created_by) VALUES ($1,$2,$3,$4)', [String(foreign._id), foreign.name, foreign.type, String(other._id)]); + await pg.query('INSERT INTO messages (pod_id,user_id,content) VALUES ($1,$2,$3)', [String(foreign._id), String(other._id), 'foreign content must stay private']); + for (const suffix of ['context', 'messages']) { + const result = await request(app).get(`/api/agents/runtime/pods/${pod._id}/${suffix}`).set(seat()).expect(200); + expect(JSON.stringify(result.body)).toContain('wave1 durable chat witness'); + const refused = await request(app).get(`/api/agents/runtime/pods/${foreign._id}/${suffix}`).set(seat()).expect(403); + expect(JSON.stringify(refused.body)).not.toContain('foreign content must stay private'); + } + }); + + test('CAP renew changes only the matching delivery nonce owned by this seat', async () => { + const old = new Date(Date.now() - 60000); + const event = await AgentEvent.create({ agentName: 'openclaw', instanceId: 'verify-wave1', podId: pod._id, + type: 'verify.wave1', status: 'delivered', deliveryNonce: 'current-nonce', deliveredAt: old }); + const foreign = await AgentEvent.create({ agentName: 'openclaw', instanceId: 'foreign-seat', podId: pod._id, + type: 'verify.wave1', status: 'delivered', deliveryNonce: 'foreign-nonce', deliveredAt: old }); + const url = '/api/agents/runtime/events/renew'; + const wrong = await request(app).post(url).set(seat()).send({ events: [{ eventId: String(event._id), deliveryId: 'old-nonce' }] }).expect(200); + expect(wrong.body.renewed).toEqual([]); + expect((await AgentEvent.findById(event._id)).deliveredAt.getTime()).toBe(old.getTime()); + const valid = await request(app).post(url).set(seat()).send({ events: [ + { eventId: String(event._id), deliveryId: 'current-nonce' }, + { eventId: String(foreign._id), deliveryId: 'foreign-nonce' }, + ] }).expect(200); + expect(valid.body.renewed).toEqual([String(event._id)]); + const stored = await AgentEvent.findById(event._id); + expect(stored.deliveredAt.getTime()).toBeGreaterThan(old.getTime()); + expect(stored.status).toBe('delivered'); + const untouched = await AgentEvent.findById(foreign._id); + expect(untouched.deliveredAt.getTime()).toBe(old.getTime()); + expect(untouched.status).toBe('delivered'); + }); +}); diff --git a/backend/__tests__/utils/testUtils.js b/backend/__tests__/utils/testUtils.js index f3a6e5abec..209fcf663f 100644 --- a/backend/__tests__/utils/testUtils.js +++ b/backend/__tests__/utils/testUtils.js @@ -123,6 +123,14 @@ const clearMongoDb = async () => { let pgDb; let pgPool; +const resetRealPgFixtures = async () => { + // Claims self-bootstrap outside schema.sql and have no FK to messages. + // Reset them before reusing message IDs, including on a warm fixture DB. + const claims = await pgPool.query("SELECT to_regclass('public.message_claims') AS relation"); + if (claims.rows[0].relation) await pgPool.query('TRUNCATE TABLE message_claims'); + await pgPool.query('TRUNCATE TABLE messages, pod_members, pods, users RESTART IDENTITY CASCADE'); +}; + const setupPgDb = async () => { try { if (useRealServices()) { @@ -143,7 +151,7 @@ const setupPgDb = async () => { await runPgMigrations(pgPool); // Same cross-file-contamination concern as Mongo: TRUNCATE on setup so // each suite starts with an empty schema. - await pgPool.query('TRUNCATE TABLE messages, pod_members, pods, users RESTART IDENTITY CASCADE'); + await resetRealPgFixtures(); console.log('[tier1] Connected to real Postgres, applied migrations, truncated tables'); return pgPool; } @@ -203,7 +211,7 @@ const clearPgDb = async () => { if (!pgPool) return; // Real PG: TRUNCATE ... CASCADE handles FK ordering; pg-mem doesn't support TRUNCATE. if (useRealServices()) { - await pgPool.query('TRUNCATE TABLE messages, pod_members, pods, users RESTART IDENTITY CASCADE'); + await resetRealPgFixtures(); } else { await pgPool.query('DELETE FROM messages'); await pgPool.query('DELETE FROM pod_members'); diff --git a/backend/server.ts b/backend/server.ts index 8baf424c5b..e2400ec0e2 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -75,8 +75,9 @@ const AgentMentionService = require('./services/agentMentionService'); const { createPgBoot } = require('./services/pgBootService'); const { setPgMountProbe, routerIsMounted } = require('./services/pgBootService'); -// Global flag to track PostgreSQL availability -let pgAvailable = false; + +// Load file-based configuration before deciding which PG routes to require. +dotenv.config(); if (process.env.PG_HOST) { pgMessageRoutes = require('./routes/pg-messages'); @@ -84,9 +85,11 @@ if (process.env.PG_HOST) { _PGPod = require('./models/pg/Pod'); } -// Load environment variables -dotenv.config(); - +/** Construct the production mount table without connecting databases or starting workers. + * Callers own database setup and close app.locals.socketServer after their HTTP run. + */ +const buildApp = ({ startServices = false } = {}) => { + let pgAvailable = false; // Initialize express app const app = express(); // Trust the reverse proxy (Cloudflare → ingress → backend). Without this, @@ -151,7 +154,7 @@ const { bindSocketIO: bindAgentTypingSocketIO } = require('./services/agentTypin const { bindSocketIO: bindTaskEventSocketIO } = require('./services/taskEventService'); // Socket.io Redis adapter initialization is async in K8s mode -(async () => { +if (startServices) (async () => { try { await socketConfig.init(io); agentWebSocketService.init(io); @@ -163,7 +166,6 @@ const { bindSocketIO: bindTaskEventSocketIO } = require('./services/taskEventSer } })(); -const PORT = process.env.PORT || 5000; // Middleware app.use( @@ -329,7 +331,7 @@ if (require('./config/devRoutes').devRoutesEnabled()) { } // Connect to MongoDB (for posts and user data) -connectDB(); +if (startServices) connectDB(); // Bootstrap agent registry after MongoDB connects const mongoose = require('mongoose'); @@ -337,8 +339,8 @@ const AgentBootstrapService = require('./services/agentBootstrapService'); const { ensureLocalDevLogin } = require('./services/localDevLoginService'); const { AgentInstallation } = require('./models/AgentRegistry'); -mongoose.connection.once('open', () => { - if (process.env.NODE_ENV !== 'test') { +if (startServices) mongoose.connection.once('open', () => { + if (startServices && process.env.NODE_ENV !== 'test') { (async () => { try { await ensureLocalDevLogin(); @@ -385,10 +387,12 @@ mongoose.connection.once('open', () => { const schedulerService = require('./services/schedulerService'); const discordGatewayService = require('./services/discordGatewayService'); const { startBackgroundServices, shouldRunBackgroundServices } = require('./services/backgroundWorkerStart'); -startBackgroundServices(process.env, { - scheduler: schedulerService, - discordGateway: discordGatewayService, -}); +if (startServices) { + startBackgroundServices(process.env, { + scheduler: schedulerService, + discordGateway: discordGatewayService, + }); +} // PostgreSQL status is mounted unconditionally, and NOT as a placeholder: // checkStatus reads the pool and the schema itself, so it answers @@ -400,6 +404,10 @@ startBackgroundServices(process.env, { // by the frontend after a GET said available:true (SocketContext.tsx:45-49), // i.e. only when the pool is up. app.use('/api/pg/status', pgStatusRoutes); +if (!startServices && process.env.PG_HOST) { + app.use('/api/pg/messages', pgMessageRoutes); + pgAvailable = true; +} // Connect to PostgreSQL if configured (for chat functionality). // @@ -412,7 +420,7 @@ app.use('/api/pg/status', pgStatusRoutes); // restart. PG itself was healthy (331ms from that same pod). The retry with // backoff, the late mount and the state the health route reads all live in // services/pgBootService.ts (TASK-168). -if (process.env.PG_HOST) { +if (startServices && process.env.PG_HOST) { // Readiness asks the route table, not this block's own bookkeeping (TASK-168): // it checks that this very router is on the app, so a pod that mounts PG late // starts passing its readiness probe without being restarted. @@ -438,7 +446,7 @@ if (process.env.PG_HOST) { // Kick off the daily 30-day message retention cron. Kept out // of schedulerService.ts on purpose so other tracks can edit // that file without stomping on this cron. - if (shouldRunBackgroundServices(process.env)) { + if (startServices && shouldRunBackgroundServices(process.env)) { try { const { initPgRetention } = require('./services/pgRetentionService'); initPgRetention(); @@ -469,7 +477,7 @@ if (process.env.PG_HOST) { // an unhandled rejection at boot. console.error('Error starting the PostgreSQL boot retry:', err?.message || err); }); -} else { +} else if (!process.env.PG_HOST) { pgAvailable = false; console.log( 'PostgreSQL connection not configured. Chat functionality will use MongoDB.', @@ -829,14 +837,36 @@ io.on('connection', (socket: any) => { socket.emit('welcome', { message: 'Connected to chat server successfully' }); }); -// Start the server only when executed directly -if (require.main === module) { - require('./services/hostedShutdownService').installHostedShutdown(server); - server.listen(PORT, () => console.log(`Server running on port ${PORT}`)); -} + app.locals.httpServer = server; + app.locals.socketServer = io; + app.locals.authorizeSocketPodAccess = authorizeSocketPodAccess; + return app; +}; + +// Preserve the legacy app/server exports lazily. Merely importing the factory +// must not connect Mongo, start PostgreSQL retries or launch background workers. +let defaultApp: any; +const getDefaultApp = () => { + if (!defaultApp) defaultApp = buildApp({ startServices: true }); + return defaultApp; +}; +// Service startup owns process-wide pools, readiness and workers. Only the lazy +// singleton may start it; independent HTTP test apps use already-prepared stores. +const createApp = ({ startServices = false } = {}) => { + if (startServices) throw new Error('Service startup is singleton-only; use the app/server exports'); + return buildApp(); +}; module.exports = { - app, - server, - authorizeSocketPodAccess, + createApp, + get app() { return getDefaultApp(); }, + get server() { return getDefaultApp().locals.httpServer; }, + get authorizeSocketPodAccess() { return getDefaultApp().locals.authorizeSocketPodAccess; }, }; + +if (require.main === module) { + const app = getDefaultApp(); + const port = process.env.PORT || 5000; + require('./services/hostedShutdownService').installHostedShutdown(app.locals.httpServer); + app.locals.httpServer.listen(port, () => console.log(`Server running on port ${port}`)); +} diff --git a/cli/__tests__/verify.test.mjs b/cli/__tests__/verify.test.mjs new file mode 100644 index 0000000000..eb1b2f7aea --- /dev/null +++ b/cli/__tests__/verify.test.mjs @@ -0,0 +1,65 @@ +import { jest, describe, it, expect, afterEach, afterAll } from '@jest/globals'; +import { Command } from 'commander'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { execFileSync } from 'child_process'; +import { registerVerify, verificationPlan } from '../src/commands/verify.js'; +const repo = mkdtempSync(join(tmpdir(), 'commonly-verify-')); +mkdirSync(join(repo, 'backend/node_modules/jest/bin'), { recursive: true }); +mkdirSync(join(repo, 'backend/__tests__/service'), { recursive: true }); +for (const file of ['node_modules/jest/bin/jest.js', '__tests__/service/verify.wave1.test.js', '__tests__/service/verify.firstReply.test.js']) { + writeFileSync(join(repo, 'backend', file), ''); +} +afterAll(() => rmSync(repo, { recursive: true, force: true })); +const env = { MONGO_URI: 'mongodb://mongo:27017/verify527', PG_HOST: 'pg', PG_DATABASE: 'verify527' }; + +afterEach(() => { process.exitCode = 0; jest.restoreAllMocks(); }); +describe('commonly verify shared scenario selector', () => { + it('registers verify through the shipped CLI entrypoint', () => { + const entry = fileURLToPath(new URL('../src/index.js', import.meta.url)); + const help = execFileSync(process.execPath, [entry, 'verify', '--help'], { encoding: 'utf8', timeout: 15000 }); + expect(help).toContain('verify'); + expect(help).toMatch(/wave1 or\s+first-reply/); + }); + it.each(['mongodb://mongo1:27017,mongo2:27017/verify527', 'mongodb+srv://mongo.example.test/verify527'])('accepts MongoDB seed-list/SRV database paths: %s', (uri) => { + expect(verificationPlan('first-reply', repo, { ...env, MONGO_URI: uri }).options.env.MONGO_URI).toBe(uri); + }); + it.each(['wave1', 'first-reply'])('selects the existing %s HTTP harness without a shell', (scenario) => { + const plan = verificationPlan(scenario, repo, env); + expect(plan.command).toBe(process.execPath); + expect(plan.args).toContain('--runTestsByPath'); + expect(plan.args.at(-1)).toContain(scenario === 'wave1' ? 'verify.wave1' : 'verify.firstReply'); + expect(plan.options.env.INTEGRATION_TEST).toBe('true'); + expect(plan.options.shell).toBeUndefined(); + }); + it.each([ + { ...env, MONGO_URI: 'mongodb://mongo:27017/commonly' }, + { ...env, PG_DATABASE: 'production' }, + { ...env, PG_HOST: '' }, + { ...env, MONGO_URI: undefined }, + ])('refuses non-test or implicit databases before spawning %#', (unsafe) => { + expect(() => verificationPlan('first-reply', repo, unsafe)).toThrow('dedicated test/verify'); + }); + it('refuses unknown scenarios and missing checkout dependencies', () => { + expect(() => verificationPlan('arbitrary', repo, env)).toThrow('Unknown scenario'); + expect(() => verificationPlan('first-reply', '/no-such-checkout', env)).toThrow('Commonly checkout'); + }); + it('propagates the scenario exit status through the actual Commander action', async () => { + const prior = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]])); + Object.assign(process.env, env); + try { + const run = jest.fn(() => ({ status: 7 })); + const program = new Command(); + registerVerify(program, run); + await program.parseAsync(['node', 'commonly', 'verify', 'first-reply', '--repo', repo]); + expect(run).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(7); + } finally { + for (const key of Object.keys(env)) { + if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; + } + } + }); +}); diff --git a/cli/src/commands/verify.js b/cli/src/commands/verify.js new file mode 100644 index 0000000000..c83f6d097b --- /dev/null +++ b/cli/src/commands/verify.js @@ -0,0 +1,48 @@ +/** Repository verification entry point; scenario implementations live in backend. */ +import { existsSync } from 'fs'; +import { resolve, join } from 'path'; +import { spawnSync } from 'child_process'; + +const SCENARIOS = { + wave1: '__tests__/service/verify.wave1.test.js', + 'first-reply': '__tests__/service/verify.firstReply.test.js', +}; +const testDatabase = (name) => /^(test|verify)([-_0-9]|$)|[-_]test$/.test(name || ''); + +export const verificationPlan = (scenario, repo, env = process.env) => { + const suite = SCENARIOS[scenario]; + if (!suite) throw new Error(`Unknown scenario: ${scenario}. Choose wave1 or first-reply.`); + let mongoDatabase = ''; + // MongoDB authorities can contain comma-separated seed hosts, unlike WHATWG URLs. + const mongoPath = /^mongodb(?:\+srv)?:\/\/[^/?#]+\/([^?#]*)/.exec(env.MONGO_URI || ''); + try { mongoDatabase = decodeURIComponent(mongoPath?.[1] || ''); } catch { /* refused below */ } + if (!env.PG_HOST || !testDatabase(env.PG_DATABASE) || !testDatabase(mongoDatabase)) { + throw new Error('Set MONGO_URI, PG_HOST and PG_DATABASE to dedicated test/verify databases. Fixture setup clears their data.'); + } + const cwd = resolve(repo, 'backend'); + const runner = join(cwd, 'node_modules/jest/bin/jest.js'); + if (!existsSync(join(cwd, suite)) || !existsSync(runner)) { + throw new Error('Use --repo with a Commonly checkout containing the scenario and installed backend dependencies.'); + } + return { command: process.execPath, args: [runner, '--runInBand', '--forceExit', '--runTestsByPath', suite, + ...(env.COMMONLY_VERIFY_CACHE_DIR ? ['--cacheDirectory', env.COMMONLY_VERIFY_CACHE_DIR] : [])], + options: { cwd, stdio: 'inherit', env: { ...env, INTEGRATION_TEST: 'true' } } }; +}; + +export const registerVerify = (program, run = spawnSync) => { + program.command('verify ') + .description('Run shared real-HTTP scenarios against isolated test databases (wave1 or first-reply)') + .option('--repo ', 'Commonly checkout with backend test dependencies', process.cwd()) + .addHelpText('after', '\nUse Node 22 and explicit MONGO_URI, PG_HOST and PG_DATABASE. Fixture databases are cleared.\nRun inside the bounded test container when available; this command does not build images or contact a live instance.\n') + .action((scenario, options) => { + try { + const plan = verificationPlan(scenario, options.repo); + const result = run(plan.command, plan.args, plan.options); + if (result.error) throw result.error; + process.exitCode = result.status === null ? 1 : result.status; + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } + }); +}; diff --git a/cli/src/index.js b/cli/src/index.js index d15fc4bd70..2f719bcc2d 100755 --- a/cli/src/index.js +++ b/cli/src/index.js @@ -20,6 +20,7 @@ import { registerAgent } from './commands/agent.js'; import { registerDaemon } from './commands/daemon.js'; import { registerPod } from './commands/pod.js'; import { registerDev } from './commands/dev.js'; +import { registerVerify } from './commands/verify.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8')); @@ -51,6 +52,7 @@ registerPod(program); // Local dev environment registerDev(program); +registerVerify(program); // Each subcommand owns its own `--instance ` flag; a program-level // duplicate shadowed the subcommand value on commander v12, causing diff --git a/docs/cli/README.md b/docs/cli/README.md index 3a2b30d9be..ba516168be 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -394,3 +394,21 @@ Harmless — leaked open handles from setTimeout in a test. Does not indicate a - [ADR-005](../adr/ADR-005-local-cli-wrapper-driver.md) — local CLI wrapper design - [ADR-006](../adr/ADR-006-webhook-sdk-and-self-serve-install.md) — webhook SDK + self-serve install design - [ADR-004](../adr/ADR-004-commonly-agent-protocol.md) — CAP (the four HTTP verbs the CLI talks) + +## Verify local HTTP scenarios + +From a Commonly checkout with backend test dependencies installed, use Node 22: + +```sh +commonly verify first-reply --repo /path/to/commonly +commonly verify wave1 --repo /path/to/commonly +``` + +Set `MONGO_URI`, `PG_HOST` and `PG_DATABASE` explicitly to dedicated test databases, +plus `PG_PORT`, `PG_USER` and `PG_PASSWORD` as needed. These commands run the shared +Tier 1 Jest scenarios with real HTTP, Mongo and PostgreSQL; fixture setup clears +the database contents. Use names such as `verify527`, `test-local` or +`commonly-test`. The first-reply scenario uses a loopback model-provider fixture; +its hosted runtime, BYO CAP client, authentication and message storage are real. +Run inside the bounded Node 22 test container when available. No images are built. +`COMMONLY_VERIFY_CACHE_DIR` optionally selects a reusable Jest cache directory.