From ede7daffd674805d6d2884b8064887ae887a43a8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:22 -0700 Subject: [PATCH 1/8] test(api): add production app factory and wave-one HTTP scenarios Construct the production mount table without database boot or worker startup. Preserve legacy app/server imports lazily and the direct server entry point. Exercise grant attenuation, approval ownership, OAuth replay, upload scope, computer capability checks and CAP reads/renewal with real auth and stores. Written by Sprint Impl, a Commonly agent https://commonly.me/pods/6a692a1be833c668acdb84cf Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../__tests__/service/verify.wave1.test.js | 205 ++++++++++++++++++ backend/server.ts | 58 +++-- 2 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 backend/__tests__/service/verify.wave1.test.js diff --git a/backend/__tests__/service/verify.wave1.test.js b/backend/__tests__/service/verify.wave1.test.js new file mode 100644 index 0000000000..a4c18a2539 --- /dev/null +++ b/backend/__tests__/service/verify.wave1.test.js @@ -0,0 +1,205 @@ +/** 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(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/server.ts b/backend/server.ts index 13a0e2de4b..18f18b7dc4 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -75,8 +75,6 @@ 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; if (process.env.PG_HOST) { pgMessageRoutes = require('./routes/pg-messages'); @@ -87,6 +85,11 @@ if (process.env.PG_HOST) { // 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 createApp = ({ 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( @@ -328,7 +330,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'); @@ -336,8 +338,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(); @@ -384,7 +386,7 @@ mongoose.connection.once('open', () => { const schedulerService = require('./services/schedulerService'); const discordGatewayService = require('./services/discordGatewayService'); -if (process.env.NODE_ENV !== 'test') { +if (startServices && process.env.NODE_ENV !== 'test') { console.log('Starting summarizer scheduler...'); schedulerService.start(); @@ -403,6 +405,10 @@ if (process.env.NODE_ENV !== 'test') { // 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). // @@ -415,7 +421,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. @@ -441,7 +447,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 (process.env.NODE_ENV !== 'test') { + if (startServices && process.env.NODE_ENV !== 'test') { try { const { initPgRetention } = require('./services/pgRetentionService'); initPgRetention(); @@ -472,7 +478,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.', @@ -832,13 +838,29 @@ 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) { - 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 = createApp({ startServices: true }); + return defaultApp; +}; 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; + app.locals.httpServer.listen(port, () => console.log(`Server running on port ${port}`)); +} From 0a4eb7f451ff87493a63ee825ce4098faec5a793 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:35:19 -0700 Subject: [PATCH 2/8] test(grants): draft real-PG connector HTTP scenario Co-authored-by: vera Agent-Model: codex/gpt-6.1-sol/high --- .../service/verify.connectorGrant.test.js | 178 ++++++++++++++++++ docs/plans/connector-grant-verification.md | 60 ++++++ 2 files changed, 238 insertions(+) create mode 100644 backend/__tests__/service/verify.connectorGrant.test.js create mode 100644 docs/plans/connector-grant-verification.md diff --git a/backend/__tests__/service/verify.connectorGrant.test.js b/backend/__tests__/service/verify.connectorGrant.test.js new file mode 100644 index 0000000000..596f03f80d --- /dev/null +++ b/backend/__tests__/service/verify.connectorGrant.test.js @@ -0,0 +1,178 @@ +/** TASK-289: connector-grant HTTP scenario for TASK-514's production app factory. + * Real auth, Mongo and PostgreSQL. GitHub is intercepted only at Axios's HTTP + * adapter; no route, middleware, service or database model is mocked. + * + * The existing GitHub App connect route is admin-only. The first case protects + * that boundary; the broker case explicitly uses an admin, and does NOT claim + * ordinary-user onboarding; the acceptance scope remains unresolved. + * This mutating fixture is local/CI only, never a staging/prod walk. + */ +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const axios = require('axios'); +const { generateKeyPairSync, randomUUID } = require('crypto'); +const { setupMongoDb, closeMongoDb, setupPgDb, closePgDb } = require('../utils/testUtils'); +const User = require('../../models/User'); +const Pod = require('../../models/Pod'); +const Installable = require('../../models/Installable'); +const Integration = require('../../models/Integration'); +const { AgentInstallation } = require('../../models/AgentRegistry'); +const { buildGithubToolInstallable } = require('../../services/installable/toolInstallables'); +const AgentIdentityService = require('../../services/agentIdentityService'); +const { digestArgs } = require('../../models/ToolCall'); +const { hash } = require('../../utils/secret'); + +const tier1 = process.env.INTEGRATION_TEST === 'true' ? describe : describe.skip; +const rpc = (response) => { + const frame = response.text.split('\n').find((line) => line.startsWith('data:')); + return frame ? JSON.parse(frame.slice(5)) : response.body; +}; + +tier1('commonly verify connector-grant — admin bootstrap boundary', () => { + let app; + let pg; + let admin; + let member; + let agent; + let pod; + let previousAdapter; + let previousEnv; + const suffix = randomUUID().slice(0, 8); + const instanceId = `verify-grant-${suffix}`; + const seatToken = `cm_agent_verify-${randomUUID()}`; + const installationId = '289001'; + const repository = { owner: 'verify-fixture', repo: 'one-repo' }; + const upstreamToken = `fixture-installation-${suffix}`; + const vendorRequests = []; + const headers = (token) => ({ Authorization: `Bearer ${token}` }); + const humanToken = (user) => jwt.sign({ id: String(user._id) }, process.env.JWT_SECRET); + + beforeAll(async () => { + const envKeys = ['GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY', 'GITHUB_PAT']; + previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); + const keys = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + }); + process.env.GITHUB_APP_ID = '289'; + process.env.GITHUB_APP_PRIVATE_KEY = keys.privateKey; + delete process.env.GITHUB_PAT; + previousAdapter = axios.defaults.adapter; + axios.defaults.adapter = async (config) => { + const url = new URL(config.url); + const method = String(config.method).toUpperCase(); + vendorRequests.push({ method, path: url.pathname }); + const response = (data) => ({ data, status: 200, statusText: 'OK', headers: {}, config }); + expect(url.origin).toBe('https://api.github.com'); + if (method === 'POST' && url.pathname === `/app/installations/${installationId}/access_tokens`) { + const signed = String(config.headers.Authorization).replace(/^Bearer /, ''); + expect(jwt.verify(signed, keys.publicKey, { algorithms: ['RS256'] }).iss).toBe('289'); + return response({ token: upstreamToken, expires_at: new Date(Date.now() + 3600000).toISOString() }); + } + if (method === 'GET' && url.pathname === `/repos/${repository.owner}/${repository.repo}/issues`) { + expect(config.headers.Authorization).toBe(`Bearer ${upstreamToken}`); + return response([{ number: 289, title: 'Granted issue', body: 'Fixture text', + html_url: 'https://github.com/verify-fixture/one-repo/issues/289', labels: [] }]); + } + throw new Error(`Unexpected vendor HTTP request: ${method} ${url.pathname}`); + }; + + const { createApp } = require('../../server'); + await setupMongoDb(); + pg = await setupPgDb(); + app = createApp(); + admin = await User.create({ username: `verify-admin-${suffix}`, email: `admin-${suffix}@test.invalid`, + password: 'fixture-password', role: 'admin' }); + member = await User.create({ username: `verify-member-${suffix}`, email: `member-${suffix}@test.invalid`, + password: 'fixture-password', role: 'user' }); + agent = await User.create({ username: AgentIdentityService.buildAgentUsername('openclaw', instanceId), + email: `seat-${suffix}@test.invalid`, password: 'fixture-password', isBot: true, botType: 'agent', + botMetadata: { agentName: 'openclaw', instanceId }, + agentRuntimeTokens: [{ tokenHash: hash(seatToken), lastUsedAt: new Date() }] }); + pod = await Pod.create({ name: `Verify grant ${suffix}`, type: 'team', publicRead: false, + joinPolicy: 'invite-only', createdBy: admin._id, members: [admin._id, member._id, agent._id] }); + await AgentInstallation.create({ agentName: 'openclaw', instanceId, podId: pod._id, installedBy: admin._id, + version: '1.0.0', status: 'active', config: { runtime: { runtimeType: 'native' } } }); + await Installable.create(buildGithubToolInstallable()); + for (const user of [admin, member, agent]) { + await pg.query('INSERT INTO users (_id, username, is_bot) VALUES ($1,$2,$3)', + [String(user._id), user.username, Boolean(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(admin._id)]); + }, 120000); // Cold production-route loading and real-store bootstrap, not a request timeout. + + afterAll(async () => { + axios.defaults.adapter = previousAdapter; + if (previousEnv) { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + 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('a fresh ordinary member cannot bind the server-held App credential', async () => { + const result = await request(app).post('/api/integrations/github-app').set(headers(humanToken(member))) + .send({ installationId, ...repository }); + expect(result.status).toBe(403); + expect(result.body.message).toBe('Admin access required'); + expect(await Integration.countDocuments({ type: 'github-app', installationId })).toBe(0); + expect(vendorRequests).toEqual([]); + }); + + test('admin connects one repository; the agent can spend only its read grant and PG records all outcomes', async () => { + const connected = await request(app).post('/api/integrations/github-app').set(headers(humanToken(admin))) + .send({ installationId, ...repository }).expect(201); + const connectionId = connected.body.integration._id; + const minted = await request(app).post('/api/grants').set(headers(humanToken(admin))).send({ + connectionId, target: { kind: 'pod', id: String(pod._id) }, audience: [String(agent._id)], + tools: ['github.list_issues'], writeMode: 'read', budget: { calls: 5 }, + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }).expect(201); + const grantId = minted.body.grantId; + const call = async (id, name, args) => rpc(await request(app).post(`/api/mcp/grants/${grantId}`) + .set(headers(seatToken)).set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }).expect(200)); + const allowed = await call(1, 'github.list_issues', {}); + expect(allowed.result.isError).not.toBe(true); + expect(JSON.parse(allowed.result.content[0].text).issues[0].number).toBe(289); + expect(vendorRequests).toEqual([ + { method: 'POST', path: `/app/installations/${installationId}/access_tokens` }, + { method: 'GET', path: '/repos/verify-fixture/one-repo/issues' }, + ]); + const outsideTool = await call(2, 'github.create_issue', { title: 'Must not write' }); + expect(outsideTool.result.isError).toBe(true); + expect(JSON.parse(outsideTool.result.content[0].text).error).toBe('tool_not_allowed'); + const outsideRepo = await call(3, 'github.list_issues', { owner: 'another-owner', repo: 'another-repo' }); + expect(outsideRepo.result.isError).toBe(true); + expect(JSON.parse(outsideRepo.result.content[0].text).error).toBe('invalid_tool_args'); + expect(vendorRequests).toHaveLength(2); + + const rows = (await pg.query('SELECT * FROM tool_calls WHERE grant_id=$1 ORDER BY occurred_at,call_id', [grantId])).rows; + expect(rows).toHaveLength(3); + expect(rows.map((row) => [row.tool, row.outcome, row.reason])).toEqual(expect.arrayContaining([ + ['github.list_issues', 'ok', null], + ['github.create_issue', 'refused', 'tool_not_allowed'], + ['github.list_issues', 'refused', 'invalid_tool_args'], + ])); + for (const row of rows) { + expect(row.agent_user_id).toBe(String(agent._id)); + expect(row.pod_id).toBe(String(pod._id)); + expect(row.args_digest).toMatch(/^[a-f0-9]{64}$/); + } + expect(rows.find((row) => row.outcome === 'ok').credential_owner_id).toBe(String(admin._id)); + expect(rows.find((row) => row.outcome === 'ok').args_digest).toBe(digestArgs({})); + const trail = await request(app).get(`/api/grants/${grantId}/calls`).set(headers(humanToken(admin))).expect(200); + expect(JSON.stringify(trail.body)).toContain(rows[0].call_id); + for (const secret of [upstreamToken, seatToken, process.env.GITHUB_APP_PRIVATE_KEY, 'Must not write', 'another-owner', 'another-repo']) { + expect(JSON.stringify({ allowed, outsideTool, outsideRepo, trail: trail.body, rows })).not.toContain(secret); + } + }); +}); diff --git a/docs/plans/connector-grant-verification.md b/docs/plans/connector-grant-verification.md new file mode 100644 index 0000000000..e58cb6c6b3 --- /dev/null +++ b/docs/plans/connector-grant-verification.md @@ -0,0 +1,60 @@ +# Connector grant verification (TASK-289) + +Status: draft. The scenario extends TASK-514's production app factory at +`d8a9494d7` on `test/task-514-http-scenarios`. It is not ordinary-user onboarding +clearance and has no public activation or merge clearance. + +## Current scenario + +`backend/__tests__/service/verify.connectorGrant.test.js` uses the production +HTTP mount table and authentication, MongoDB and a real PostgreSQL server. No +route, middleware, provider service or database model is mocked. GitHub requests +are intercepted at Axios's HTTP adapter. The fixture generates its own RSA App +key and installation token; no real vendor account or credential is used. + +The checks cover: + +- A fresh ordinary member cannot connect the server-held GitHub App credential. +- An administrator connects one repository and issues a read-only grant to one + current agent in a private, invite-only pod on the narrowed native runtime. +- The authenticated agent reads issues through the real MCP HTTP endpoint. +- An ungranted write and caller-supplied repository are refused before vendor + I/O. The provider sees only the configured installation and repository. +- PostgreSQL persists all three outcomes with grant, caller, pod, credential + owner where resolved, and argument digests. The HTTP audit read exposes them. +- Responses and audit rows exclude token values, the App key and raw arguments. + +## Unresolved acceptance boundary + +At main `4eed277a8`, `POST /api/integrations/github-app` is explicitly admin-only; +the generic connection route also rejects `github-app`. This protects the +server-held App credential. The scenario therefore names its happy path +**admin bootstrap**. It does not promote the ordinary member to admin or seed +an ordinary user's connection directly to make the requested path appear green. + +TASK-289's request for a new user to connect GitHub still needs either a +user-owned connection flow or an explicit narrowing of this acceptance scope. +That ruling remains pending. The security boundary remains unchanged. + +## Harness integration and running + +This is a local/CI mutating fixture. The shared setup drops its Mongo database +and initializes/truncates its PostgreSQL test tables. Supply fresh, isolated +test databases. Never point it at a shared, staging or production database. + +With the backend test dependencies installed and real test databases configured: + +```sh +INTEGRATION_TEST=true npm --prefix backend test -- --runInBand \ + --runTestsByPath __tests__/service/verify.connectorGrant.test.js +``` + +The `commonly verify` CLI registry is not present at the referenced TASK-514 +head. Its owner must wire the `connector-grant` name to this scenario; this +change does not introduce a second runner. SRE's matching staging walk uses +the walk account and separate, non-destructive procedures. + +A 512 MiB Node heap cap and isolated transpilation were used for the initial +local behavioral run. Both cases passed with real PG and the process exited with code 0. Jest warned +about a delayed exit before it settled. This is not a final +verification verdict or evidence of ordinary-user onboarding. From 6779034173601fa11e9a0b571865f45503eb7468 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:30:58 -0700 Subject: [PATCH 3/8] feat(cli): select shared real-HTTP verification scenarios Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium Co-authored-by: vera Agent-Model: codex/gpt-6.1-sol/high --- cli/__tests__/verify.test.mjs | 54 +++++++++++++++++++++++++++++++++++ cli/src/commands/verify.js | 46 +++++++++++++++++++++++++++++ cli/src/index.js | 2 ++ docs/cli/README.md | 18 ++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 cli/__tests__/verify.test.mjs create mode 100644 cli/src/commands/verify.js diff --git a/cli/__tests__/verify.test.mjs b/cli/__tests__/verify.test.mjs new file mode 100644 index 0000000000..b2caa3f127 --- /dev/null +++ b/cli/__tests__/verify.test.mjs @@ -0,0 +1,54 @@ +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 { 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.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..b497844777 --- /dev/null +++ b/cli/src/commands/verify.js @@ -0,0 +1,46 @@ +/** 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 = ''; + try { mongoDatabase = decodeURIComponent(new URL(env.MONGO_URI).pathname.slice(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. From 0898ff9b3bc4bdba335cf0efb87ae46e86047fa3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:49:49 -0700 Subject: [PATCH 4/8] test(connectors): wire approved admin bootstrap verification Co-authored-by: vera Agent-Model: codex/gpt-6.1-sol/high --- .../service/verify.connectorGrant.test.js | 3 +- cli/__tests__/verify.test.mjs | 23 +++++++++++--- cli/src/commands/verify.js | 9 ++++-- docs/cli/README.md | 4 +++ docs/plans/connector-grant-verification.md | 31 ++++++++++--------- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/backend/__tests__/service/verify.connectorGrant.test.js b/backend/__tests__/service/verify.connectorGrant.test.js index 596f03f80d..2436de761e 100644 --- a/backend/__tests__/service/verify.connectorGrant.test.js +++ b/backend/__tests__/service/verify.connectorGrant.test.js @@ -4,7 +4,8 @@ * * The existing GitHub App connect route is admin-only. The first case protects * that boundary; the broker case explicitly uses an admin, and does NOT claim - * ordinary-user onboarding; the acceptance scope remains unresolved. + * ordinary-user onboarding. Lily approved admin bootstrap on 2026-10-25; + * ordinary-user connection acceptance is tracked separately as TASK-290. * This mutating fixture is local/CI only, never a staging/prod walk. */ const request = require('supertest'); diff --git a/cli/__tests__/verify.test.mjs b/cli/__tests__/verify.test.mjs index b2caa3f127..540f95b4cf 100644 --- a/cli/__tests__/verify.test.mjs +++ b/cli/__tests__/verify.test.mjs @@ -3,11 +3,13 @@ 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']) { +for (const file of ['node_modules/jest/bin/jest.js', '__tests__/service/verify.wave1.test.js', '__tests__/service/verify.firstReply.test.js', '__tests__/service/verify.connectorGrant.test.js']) { writeFileSync(join(repo, 'backend', file), ''); } afterAll(() => rmSync(repo, { recursive: true, force: true })); @@ -15,11 +17,23 @@ const env = { MONGO_URI: 'mongodb://mongo:27017/verify527', PG_HOST: 'pg', PG_DA afterEach(() => { process.exitCode = 0; jest.restoreAllMocks(); }); describe('commonly verify shared scenario selector', () => { - it.each(['wave1', 'first-reply'])('selects the existing %s HTTP harness without a shell', (scenario) => { + 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 }); + for (const scenario of ['wave1', 'first-reply', 'connector-grant']) expect(help).toContain(scenario); + }); + 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('connector-grant', repo, { ...env, MONGO_URI: uri }).options.env.MONGO_URI).toBe(uri); + }); + it.each([ + ['wave1', 'verify.wave1'], + ['first-reply', 'verify.firstReply'], + ['connector-grant', 'verify.connectorGrant'], + ])('selects the existing %s HTTP harness without a shell', (scenario, suite) => { 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.args.at(-1)).toContain(suite); expect(plan.options.env.INTEGRATION_TEST).toBe('true'); expect(plan.options.shell).toBeUndefined(); }); @@ -42,8 +56,9 @@ describe('commonly verify shared scenario selector', () => { const run = jest.fn(() => ({ status: 7 })); const program = new Command(); registerVerify(program, run); - await program.parseAsync(['node', 'commonly', 'verify', 'first-reply', '--repo', repo]); + await program.parseAsync(['node', 'commonly', 'verify', 'connector-grant', '--repo', repo]); expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0][1]).toContain('__tests__/service/verify.connectorGrant.test.js'); expect(process.exitCode).toBe(7); } finally { for (const key of Object.keys(env)) { diff --git a/cli/src/commands/verify.js b/cli/src/commands/verify.js index b497844777..2e1cf00bd8 100644 --- a/cli/src/commands/verify.js +++ b/cli/src/commands/verify.js @@ -6,14 +6,17 @@ import { spawnSync } from 'child_process'; const SCENARIOS = { wave1: '__tests__/service/verify.wave1.test.js', 'first-reply': '__tests__/service/verify.firstReply.test.js', + 'connector-grant': '__tests__/service/verify.connectorGrant.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.`); + if (!suite) throw new Error(`Unknown scenario: ${scenario}. Choose ${Object.keys(SCENARIOS).join(', ')}.`); let mongoDatabase = ''; - try { mongoDatabase = decodeURIComponent(new URL(env.MONGO_URI).pathname.slice(1)); } catch { /* refused below */ } + // 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.'); } @@ -29,7 +32,7 @@ export const verificationPlan = (scenario, repo, env = process.env) => { export const registerVerify = (program, run = spawnSync) => { program.command('verify ') - .description('Run shared real-HTTP scenarios against isolated test databases (wave1 or first-reply)') + .description(`Run shared real-HTTP scenarios against isolated test databases (${Object.keys(SCENARIOS).join(', ')})`) .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) => { diff --git a/docs/cli/README.md b/docs/cli/README.md index ba516168be..336d53ac12 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -402,6 +402,7 @@ 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 +commonly verify connector-grant --repo /path/to/commonly ``` Set `MONGO_URI`, `PG_HOST` and `PG_DATABASE` explicitly to dedicated test databases, @@ -410,5 +411,8 @@ 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. +The connector-grant scenario covers administrator GitHub App bootstrap, a scoped +agent read, refused calls and the PostgreSQL audit trail. GitHub HTTP is stubbed; +ordinary-user connection onboarding is tracked separately in TASK-290. 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. diff --git a/docs/plans/connector-grant-verification.md b/docs/plans/connector-grant-verification.md index e58cb6c6b3..010960a190 100644 --- a/docs/plans/connector-grant-verification.md +++ b/docs/plans/connector-grant-verification.md @@ -1,7 +1,7 @@ # Connector grant verification (TASK-289) Status: draft. The scenario extends TASK-514's production app factory at -`d8a9494d7` on `test/task-514-http-scenarios`. It is not ordinary-user onboarding +`ede7daffd` on `test/task-514-http-scenarios`. It is not ordinary-user onboarding clearance and has no public activation or merge clearance. ## Current scenario @@ -24,7 +24,7 @@ The checks cover: owner where resolved, and argument digests. The HTTP audit read exposes them. - Responses and audit rows exclude token values, the App key and raw arguments. -## Unresolved acceptance boundary +## Approved acceptance boundary At main `4eed277a8`, `POST /api/integrations/github-app` is explicitly admin-only; the generic connection route also rejects `github-app`. This protects the @@ -32,9 +32,10 @@ server-held App credential. The scenario therefore names its happy path **admin bootstrap**. It does not promote the ordinary member to admin or seed an ordinary user's connection directly to make the requested path appear green. -TASK-289's request for a new user to connect GitHub still needs either a -user-owned connection flow or an explicit narrowing of this acceptance scope. -That ruling remains pending. The security boundary remains unchanged. +Lily approved narrowing TASK-289 to admin bootstrap on 2026-10-25 +(decision `6addd6d27f8fc798a6f6ea14`). TASK-290 tracks ordinary-user connection +onboarding separately, coordinated with TASK-225's user-owned GitHub MCP path. +The security boundary remains unchanged. ## Harness integration and running @@ -45,16 +46,18 @@ test databases. Never point it at a shared, staging or production database. With the backend test dependencies installed and real test databases configured: ```sh -INTEGRATION_TEST=true npm --prefix backend test -- --runInBand \ - --runTestsByPath __tests__/service/verify.connectorGrant.test.js +commonly verify connector-grant --repo /path/to/commonly ``` -The `commonly verify` CLI registry is not present at the referenced TASK-514 -head. Its owner must wire the `connector-grant` name to this scenario; this -change does not introduce a second runner. SRE's matching staging walk uses +The CLI selector reuses Tern's isolated shared-runner commit `cecb8bc8` from +TASK-527/#2693, including its later entrypoint and Mongo seed-list coverage, +and adds `connector-grant` to the same scenario registry. No +second runner is introduced. SRE's matching staging walk uses the walk account and separate, non-destructive procedures. -A 512 MiB Node heap cap and isolated transpilation were used for the initial -local behavioral run. Both cases passed with real PG and the process exited with code 0. Jest warned -about a delayed exit before it settled. This is not a final -verification verdict or evidence of ordinary-user onboarding. +The actual CLI command passed both HTTP cases with real PostgreSQL 16 and +MongoDB 7, standard repository transforms and a 1024 MiB Node heap cap. It +returned exit code 0. The shared selector passes Jest `--forceExit`, so this +command does not prove handle cleanup. The earlier direct Jest run also passed +both cases and settled normally after a delayed-exit warning. Neither run is +evidence of ordinary-user onboarding. From da2077fe0ff6f485441311478a25d074eb6b4fa1 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:22 -0700 Subject: [PATCH 5/8] test(api): add production app factory and wave-one HTTP scenarios Construct the production mount table without database boot or worker startup. Preserve legacy app/server imports lazily and the direct server entry point. Exercise grant attenuation, approval ownership, OAuth replay, upload scope, computer capability checks and CAP reads/renewal with real auth and stores. Written by Sprint Impl, a Commonly agent https://commonly.me/pods/6a692a1be833c668acdb84cf Co-authored-by: sprint-impl Agent-Model: codex/gpt-6.1-sol/medium --- .../__tests__/service/verify.wave1.test.js | 206 ++++++++++++++++++ backend/server.ts | 80 ++++--- 2 files changed, 261 insertions(+), 25 deletions(-) create mode 100644 backend/__tests__/service/verify.wave1.test.js 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/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}`)); +} From b0156e4fecce258e7db6c9b97cd8a1933a6d63b2 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:26:56 -0700 Subject: [PATCH 6/8] fix(api): the grant-owner backfill boots only with services Main's #3026 added a boot-time grant-owner backfill as a mongoose.connection.once('open') handler. Rebasing the factory carried it inside buildApp without the startServices guard, so every createApp() test app would register it on the shared connection. It is a boot job, like the registry bootstrap beside it, so only the service singleton registers it. The wave-one scenario now asserts createApp() adds no 'open' listener. A local probe goes red (one listener added) with the guard removed. Co-Authored-By: Claude Opus 5.5 Co-authored-by: vera Agent-Model: claude/claude-opus-5-5/default --- backend/__tests__/service/verify.wave1.test.js | 4 ++++ backend/server.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/service/verify.wave1.test.js b/backend/__tests__/service/verify.wave1.test.js index 37ee4a86d2..0e276494bb 100644 --- a/backend/__tests__/service/verify.wave1.test.js +++ b/backend/__tests__/service/verify.wave1.test.js @@ -42,7 +42,11 @@ tier1('commonly verify wave 1 — production HTTP routes', () => { expect(mongoose.connection.readyState).toBe(0); await setupMongoDb(); pg = await setupPgDb(); + // A test app registers no boot job on the shared connection (the + // registry bootstrap, the grant-owner backfill): those are the singleton's. + const openListeners = mongoose.connection.listenerCount('open'); app = createApp(); + expect(mongoose.connection.listenerCount('open')).toBe(openListeners); 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); diff --git a/backend/server.ts b/backend/server.ts index e01e602daa..b8b3245ff0 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -324,7 +324,8 @@ const { backfillGrantOwners } = require('./services/grantOwnerBinding'); // refuse unbound grants. This runs at boot without holding requests, so a // revoke that lands while it is still scanning can be refused until it // finishes; the scan is idempotent and a restart re-runs it for the rest. -mongoose.connection.once('open', () => { +// A boot job, so only the service singleton registers it. +if (startServices) mongoose.connection.once('open', () => { if (process.env.NODE_ENV === 'test') return; backfillGrantOwners() .then((result: { scanned: number; bound: number; unbound: number }) => console.log( From 0a4465d424e2e0e1e613b6f435f294a016651a82 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:38:17 -0700 Subject: [PATCH 7/8] test(api): drive the wave-one OAuth scenario through /start Main's #2752 binds each login attempt to a browser cookie set at /start, with PKCE, and refuses a callback or exchange without it. The scenario created its OAuthLoginState row directly and sent no cookie, so the callback now correctly redirected without a completion code. Mint the attempt through the real /start route and carry its cookie to the callback, the replay and both exchanges, as oauthNewAccount does. Co-Authored-By: Claude Opus 5.5 Co-authored-by: vera Agent-Model: claude/claude-opus-5-5/default --- backend/__tests__/service/verify.wave1.test.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/service/verify.wave1.test.js b/backend/__tests__/service/verify.wave1.test.js index 0e276494bb..819ed7271b 100644 --- a/backend/__tests__/service/verify.wave1.test.js +++ b/backend/__tests__/service/verify.wave1.test.js @@ -114,28 +114,34 @@ tier1('commonly verify wave 1 — production HTTP routes', () => { 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'); }); + // /start mints the attempt and the browser cookie that binds its callback and exchange. + const start = await request(app).get('/api/auth/oauth/github/start').expect(302); + const browserCookie = start.headers['set-cookie'].map((cookie) => cookie.split(';')[0]).join('; '); + const startState = new URL(start.headers.location).searchParams.get('state'); + const state = await OAuthLoginState.findOne({ provider: 'github', state: startState }); + expect(state).toBeTruthy(); const callback = `/api/auth/oauth/github/callback?state=${state.state}&code=provider-code`; - const first = await request(app).get(callback).expect(302); + const inBrowser = (req) => req.set('Cookie', browserCookie); + const first = await inBrowser(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); + const replay = await inBrowser(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); + const exchanged = await inBrowser(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); + await inBrowser(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; From 928af5e600b41885f1240e146c99661387fa8151 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:40:06 -0700 Subject: [PATCH 8/8] fix(cli): verify lists only scenarios whose suite exists The selector registered first-reply, but its suite came from #2693, which closed unmerged, so `commonly verify first-reply` always failed. Drop it from the registry and the CLI docs, and add a test that every registered scenario's suite exists in this repository; re-registering first-reply reddens exactly that arm. The plan doc's status and its #2693 provenance now say where the selector lands and why first-reply is absent. Co-Authored-By: Claude Opus 5.5 Co-authored-by: vera Agent-Model: claude/claude-opus-5-5/default --- cli/__tests__/verify.test.mjs | 18 +++++++++++------- cli/src/commands/verify.js | 3 +-- docs/cli/README.md | 5 +---- docs/plans/connector-grant-verification.md | 16 +++++++++------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/cli/__tests__/verify.test.mjs b/cli/__tests__/verify.test.mjs index 540f95b4cf..8b8f66dec2 100644 --- a/cli/__tests__/verify.test.mjs +++ b/cli/__tests__/verify.test.mjs @@ -1,15 +1,15 @@ import { jest, describe, it, expect, afterEach, afterAll } from '@jest/globals'; import { Command } from 'commander'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { existsSync, 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'; +import { SCENARIOS, 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', '__tests__/service/verify.connectorGrant.test.js']) { +for (const file of ['node_modules/jest/bin/jest.js', '__tests__/service/verify.wave1.test.js', '__tests__/service/verify.connectorGrant.test.js']) { writeFileSync(join(repo, 'backend', file), ''); } afterAll(() => rmSync(repo, { recursive: true, force: true })); @@ -20,14 +20,18 @@ 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 }); - for (const scenario of ['wave1', 'first-reply', 'connector-grant']) expect(help).toContain(scenario); + for (const scenario of ['wave1', 'connector-grant']) expect(help).toContain(scenario); + }); + it('lists only scenarios whose suite exists in this repository', () => { + const backend = fileURLToPath(new URL('../../backend/', import.meta.url)); + expect(Object.keys(SCENARIOS).length).toBeGreaterThan(0); + for (const suite of Object.values(SCENARIOS)) expect(existsSync(join(backend, suite))).toBe(true); }); 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('connector-grant', repo, { ...env, MONGO_URI: uri }).options.env.MONGO_URI).toBe(uri); }); it.each([ ['wave1', 'verify.wave1'], - ['first-reply', 'verify.firstReply'], ['connector-grant', 'verify.connectorGrant'], ])('selects the existing %s HTTP harness without a shell', (scenario, suite) => { const plan = verificationPlan(scenario, repo, env); @@ -43,11 +47,11 @@ describe('commonly verify shared scenario selector', () => { { ...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'); + expect(() => verificationPlan('wave1', 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'); + expect(() => verificationPlan('wave1', '/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]])); diff --git a/cli/src/commands/verify.js b/cli/src/commands/verify.js index 2e1cf00bd8..6c6f6c905b 100644 --- a/cli/src/commands/verify.js +++ b/cli/src/commands/verify.js @@ -3,9 +3,8 @@ import { existsSync } from 'fs'; import { resolve, join } from 'path'; import { spawnSync } from 'child_process'; -const SCENARIOS = { +export const SCENARIOS = { wave1: '__tests__/service/verify.wave1.test.js', - 'first-reply': '__tests__/service/verify.firstReply.test.js', 'connector-grant': '__tests__/service/verify.connectorGrant.test.js', }; const testDatabase = (name) => /^(test|verify)([-_0-9]|$)|[-_]test$/.test(name || ''); diff --git a/docs/cli/README.md b/docs/cli/README.md index 336d53ac12..25c9e8ac37 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -400,7 +400,6 @@ Harmless — leaked open handles from setTimeout in a test. Does not indicate a 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 commonly verify connector-grant --repo /path/to/commonly ``` @@ -409,9 +408,7 @@ Set `MONGO_URI`, `PG_HOST` and `PG_DATABASE` explicitly to dedicated test databa 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. -The connector-grant scenario covers administrator GitHub App bootstrap, a scoped +`commonly-test`. The connector-grant scenario covers administrator GitHub App bootstrap, a scoped agent read, refused calls and the PostgreSQL audit trail. GitHub HTTP is stubbed; ordinary-user connection onboarding is tracked separately in TASK-290. Run inside the bounded Node 22 test container when available. No images are built. diff --git a/docs/plans/connector-grant-verification.md b/docs/plans/connector-grant-verification.md index 010960a190..7c57c2421f 100644 --- a/docs/plans/connector-grant-verification.md +++ b/docs/plans/connector-grant-verification.md @@ -1,8 +1,8 @@ # Connector grant verification (TASK-289) -Status: draft. The scenario extends TASK-514's production app factory at -`ede7daffd` on `test/task-514-http-scenarios`. It is not ordinary-user onboarding -clearance and has no public activation or merge clearance. +Status: in review (Tier 2), in #2698. The scenario runs on TASK-514's +production app factory from #2687, which merges first. It does not clear +ordinary-user onboarding, which is TASK-290. ## Current scenario @@ -49,10 +49,12 @@ With the backend test dependencies installed and real test databases configured: commonly verify connector-grant --repo /path/to/commonly ``` -The CLI selector reuses Tern's isolated shared-runner commit `cecb8bc8` from -TASK-527/#2693, including its later entrypoint and Mongo seed-list coverage, -and adds `connector-grant` to the same scenario registry. No -second runner is introduced. SRE's matching staging walk uses +The CLI selector began as Tern's shared-runner commit `cecb8bc8` from +TASK-527's #2693. That PR closed unmerged, so the selector lands here, with its +entrypoint and Mongo seed-list coverage. It registers `wave1` and +`connector-grant`. #2693's `first-reply` scenario is not registered, because +its suite never reached main, and a test now requires every registered +scenario's suite to exist in the repository. No second runner is introduced. SRE's matching staging walk uses the walk account and separate, non-destructive procedures. The actual CLI command passed both HTTP cases with real PostgreSQL 16 and