diff --git a/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js b/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js index 7590422d5b..633a81e7d0 100644 --- a/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js +++ b/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js @@ -88,4 +88,22 @@ describe('AgentInstallation wake-on-message opt-in', () => { expect(lookup).not.toHaveBeenCalled(); lookup.mockRestore(); }); + + test('defaults the server-owned GitHub issue-write capability to off', async () => { + const pod = await Pod.create({ + name: 'Capability default', + type: 'chat', + createdBy: owner._id, + members: [owner._id, guide._id], + }); + + const installation = await AgentInstallation.create({ + agentName: 'guide', + podId: pod._id, + version: '1.0.0', + installedBy: owner._id, + }); + + expect(installation.githubIssueWrite).toBe(false); + }); }); diff --git a/backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js b/backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js index 90682a631a..9e03e981fa 100644 --- a/backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js +++ b/backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js @@ -14,8 +14,11 @@ * than mapping it to a 502. Its own tests live in github.status.test.js. */ +let mockAgentInstallations = []; +let mockAgentUser = { _id: 'bot-1' }; jest.mock('../../../middleware/agentRuntimeAuth', () => jest.fn((req, res, next) => { - req.agentUser = { _id: 'bot-1' }; + if (mockAgentUser) req.agentUser = mockAgentUser; + req.agentInstallations = mockAgentInstallations; next(); })); @@ -120,6 +123,7 @@ const REMOVED_CREDENTIAL_ROUTES = [ describe('GitHub proxy routes preserve upstream credential guidance (AX #9)', () => { beforeEach(() => { jest.clearAllMocks(); + mockAgentInstallations = []; GitHubAppService.isPatConfigured.mockReturnValue(false); GitHubAppService.isConfigured.mockReturnValue(true); }); @@ -151,6 +155,91 @@ describe('GitHub proxy routes preserve upstream credential guidance (AX #9)', () }); }); +describe('GitHub issue write capability', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAgentUser = { _id: 'bot-1' }; + GitHubAppService.isPatConfigured.mockReturnValue(true); + GitHubAppService.isConfigured.mockReturnValue(true); + }); + + test.each([ + ['/issues', { title: 'untrusted agent write' }, 'createIssue'], + ['/issues/1/comment', { body: 'untrusted agent comment' }, 'addIssueComment'], + ['/issues/1/close', {}, 'closeIssue'], + ])('denies an ungranted agent token on POST %s', async (routePath, body, service) => { + mockAgentInstallations = [{ githubIssueWrite: false }]; + + const res = await request(app) + .post(`/api/github${routePath}`) + .set('Authorization', 'Bearer cm_agent_ungranted') + .send(body); + + expect(res.status).toBe(403); + expect(res.body).toEqual(expect.objectContaining({ code: 'github_issue_write_not_granted' })); + expect(GitHubAppService[service]).not.toHaveBeenCalled(); + }); + + test.each([ + ['/issues', { title: 'legacy agent write' }, 'createIssue'], + ['/issues/1/comment', { body: 'legacy agent comment' }, 'addIssueComment'], + ['/issues/1/close', {}, 'closeIssue'], + ])( + 'denies an ungranted legacy token without an attached agent user on POST %s', + async (routePath, body, service) => { + mockAgentUser = undefined; + mockAgentInstallations = [{ githubIssueWrite: false }]; + GitHubAppService[service].mockResolvedValue({}); + + const res = await request(app) + .post(`/api/github${routePath}`) + .set('Authorization', 'Bearer cm_agent_legacy') + .send(body); + + expect(res.status).toBe(403); + expect(res.body).toEqual(expect.objectContaining({ code: 'github_issue_write_not_granted' })); + expect(GitHubAppService[service]).not.toHaveBeenCalled(); + }, + ); + + it('keeps issue reads open to the same ungranted agent token', async () => { + mockAgentInstallations = [{ githubIssueWrite: false }]; + GitHubAppService.listOpenIssues.mockResolvedValue([]); + + const res = await request(app) + .get('/api/github/issues') + .set('Authorization', 'Bearer cm_agent_ungranted'); + + expect(res.status).toBe(200); + expect(GitHubAppService.listOpenIssues).toHaveBeenCalledTimes(1); + }); + + it('allows a server-granted dev installation to create an issue', async () => { + mockAgentInstallations = [{ githubIssueWrite: true }]; + GitHubAppService.createIssue.mockResolvedValue({ number: 7, title: 'dev write', html_url: 'url' }); + + const res = await request(app) + .post('/api/github/issues') + .set('Authorization', 'Bearer cm_agent_dev') + .send({ title: 'dev write' }); + + expect(res.status).toBe(201); + expect(GitHubAppService.createIssue).toHaveBeenCalledTimes(1); + }); + + it('keeps human issue writing unchanged', async () => { + GitHubAppService.createIssue.mockResolvedValue({ number: 8, title: 'human write', html_url: 'url' }); + + const res = await request(app) + .post('/api/github/issues') + .set('Authorization', 'Bearer human-jwt') + .send({ title: 'human write' }); + + expect(res.status).toBe(201); + expect(GitHubAppService.createIssue).toHaveBeenCalledTimes(1); + }); +}); + describe('routes that lent out the server GitHub credential stay removed', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/backend/__tests__/unit/routes/registry.install-runtime-type.test.js b/backend/__tests__/unit/routes/registry.install-runtime-type.test.js index a7982bbef7..1737218ad3 100644 --- a/backend/__tests__/unit/routes/registry.install-runtime-type.test.js +++ b/backend/__tests__/unit/routes/registry.install-runtime-type.test.js @@ -13,6 +13,12 @@ jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), })); +jest.mock('../../../middleware/auth', () => (_req, _res, next) => next()); + +jest.mock('../../../services/globalModelConfigService', () => ({ + getConfig: jest.fn(), +})); + jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), })); @@ -54,6 +60,10 @@ jest.mock('../../../services/agentIdentityService', () => ({ }), })); +jest.mock('../../../services/globalModelConfigService', () => ({ + getConfig: jest.fn(), +})); + jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue(true), })); @@ -70,6 +80,7 @@ const AgentTemplate = require('../../../models/AgentTemplate'); const Activity = require('../../../models/Activity'); const AgentIdentityService = require('../../../services/agentIdentityService'); const { podHasOutsiderWritableGrant } = require('../../../services/nativeOutsiderGrantPolicy'); +const GlobalModelConfigService = require('../../../services/globalModelConfigService'); const FirstContactService = require('../../../services/firstContactService'); const AgentMessageService = require('../../../services/agentMessageService'); const installRouter = require('../../../routes/registry/install'); @@ -143,6 +154,7 @@ describe('registry install runtimeType fallback', () => { // tests install a 'native' runtime (a cloud runtime) and assert the // runtimeType fallback, not the gate. User.findById.mockReturnValue(buildSelectLeanChain({ username: 'installer', role: 'admin' })); + GlobalModelConfigService.getConfig.mockResolvedValue({ openclaw: { devAgentIds: ['theo'] } }); AgentProfile.findOneAndUpdate.mockResolvedValue(true); AgentTemplate.find.mockReturnValue({ @@ -375,6 +387,67 @@ describe('registry install runtimeType fallback', () => { expect(passed).toHaveLength(160); }); + it('grants GitHub issue writes only to configured OpenClaw dev seats', async () => { + AgentRegistry.getByName.mockResolvedValue({ + agentName: 'openclaw', + displayName: 'OpenClaw', + description: 'Cloud runtime', + latestVersion: '1.0.0', + manifest: { context: { required: [] }, runtime: { type: 'standalone' } }, + }); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + + await installHandler({ + body: { + agentName: 'openclaw', + podId: 'pod-1', + version: '1.0.0', + instanceId: 'theo', + config: { runtime: { runtimeType: 'moltbot' } }, + scopes: [], + }, + user: { id: 'user-1', username: 'installer' }, + userId: 'user-1', + }, res); + + expect(AgentInstallation.install).toHaveBeenCalledWith( + 'openclaw', + 'pod-1', + expect.objectContaining({ githubIssueWrite: true }), + ); + }); + + it('keeps GitHub issue writes off for every non-dev seat, ignoring caller intent', async () => { + AgentRegistry.getByName.mockResolvedValue({ + agentName: 'openclaw', + displayName: 'OpenClaw', + description: 'Cloud runtime', + latestVersion: '1.0.0', + manifest: { context: { required: [] }, runtime: { type: 'standalone' } }, + }); + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + + await installHandler({ + body: { + agentName: 'openclaw', + podId: 'pod-1', + version: '1.0.0', + instanceId: 'community-seat', + config: { runtime: { runtimeType: 'moltbot' } }, + scopes: [], + githubIssueWrite: true, + }, + user: { id: 'user-1', username: 'installer' }, + userId: 'user-1', + }, res); + + expect(AgentInstallation.install).toHaveBeenCalledWith( + 'openclaw', + 'pod-1', + expect.objectContaining({ githubIssueWrite: false }), + ); + }); + it('keeps install successful when the first-contact trigger fails', async () => { AgentRegistry.getByName.mockResolvedValue({ agentName: 'sample-agent', diff --git a/backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js b/backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js new file mode 100644 index 0000000000..dbf1b03489 --- /dev/null +++ b/backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js @@ -0,0 +1,126 @@ +jest.mock('../../../models/AgentRegistry', () => ({ + AgentInstallation: { + find: jest.fn(), + updateMany: jest.fn(), + }, +})); +jest.mock('../../../services/githubIssueWriteCapability', () => ({ + isConfiguredDevTierGitHubIssueWriter: jest.fn(), +})); + +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { isConfiguredDevTierGitHubIssueWriter } = require('../../../services/githubIssueWriteCapability'); +const backendPackage = require('../../../package.json'); +const { + migrateGitHubIssueWriteCapability, +} = require('../../../scripts/migrate-github-issue-write-capability'); + +const findResult = (installations) => ({ + select: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue(installations), + }), +}); + +describe('migrateGitHubIssueWriteCapability', () => { + beforeEach(() => { + jest.clearAllMocks(); + AgentInstallation.updateMany.mockResolvedValue({ modifiedCount: 0 }); + }); + + test('exposes the migration through the backend package scripts for deploy operators', () => { + expect(backendPackage.scripts['migrate:github-issue-write-capability']) + .toContain('migrate-github-issue-write-capability.ts'); + }); + + test('grants every legacy install for a configured dev identity once, while leaving other identities ungranted', async () => { + AgentInstallation.find.mockReturnValue(findResult([ + { agentName: 'openclaw', instanceId: 'theo' }, + { agentName: 'openclaw', instanceId: 'theo' }, + { agentName: 'codex', instanceId: 'theo' }, + ])); + isConfiguredDevTierGitHubIssueWriter + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + AgentInstallation.updateMany.mockResolvedValue({ modifiedCount: 2 }); + + const result = await migrateGitHubIssueWriteCapability(); + + expect(AgentInstallation.find).toHaveBeenCalledWith({ + status: 'active', + githubIssueWrite: { $exists: false }, + }); + expect(isConfiguredDevTierGitHubIssueWriter).toHaveBeenNthCalledWith(1, { + agentName: 'openclaw', instanceId: 'theo', installationCount: 2, + }); + expect(isConfiguredDevTierGitHubIssueWriter).toHaveBeenNthCalledWith(2, { + agentName: 'codex', instanceId: 'theo', installationCount: 1, + }); + expect(AgentInstallation.updateMany).toHaveBeenCalledTimes(1); + expect(AgentInstallation.updateMany).toHaveBeenCalledWith( + { + agentName: 'openclaw', + instanceId: 'theo', + status: 'active', + githubIssueWrite: { $exists: false }, + }, + { $set: { githubIssueWrite: true } }, + ); + expect(result).toMatchObject({ + legacyInstallations: 3, + identitiesChecked: 2, + identitiesGranted: 1, + installationsGranted: 2, + dryRun: false, + }); + }); + + test('dry run reports the exact legacy grant plan without writing', async () => { + AgentInstallation.find.mockReturnValue(findResult([ + { agentName: 'openclaw', instanceId: 'theo' }, + { agentName: 'openclaw', instanceId: 'theo' }, + ])); + isConfiguredDevTierGitHubIssueWriter.mockResolvedValue(true); + + const result = await migrateGitHubIssueWriteCapability({ dryRun: true }); + + expect(AgentInstallation.updateMany).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + identitiesGranted: 1, + installationsGranted: 2, + dryRun: true, + }); + }); + + test('never re-grants explicit false rows: only the legacy absent-field query is eligible', async () => { + AgentInstallation.find.mockReturnValue(findResult([])); + + await migrateGitHubIssueWriteCapability(); + + expect(AgentInstallation.find).toHaveBeenCalledWith({ + status: 'active', + githubIssueWrite: { $exists: false }, + }); + expect(isConfiguredDevTierGitHubIssueWriter).not.toHaveBeenCalled(); + expect(AgentInstallation.updateMany).not.toHaveBeenCalled(); + }); + + test('normalizes a missing instanceId to the canonical default identity', async () => { + AgentInstallation.find.mockReturnValue(findResult([ + { agentName: 'openclaw' }, + ])); + isConfiguredDevTierGitHubIssueWriter.mockResolvedValue(true); + AgentInstallation.updateMany.mockResolvedValue({ modifiedCount: 1 }); + + await migrateGitHubIssueWriteCapability(); + + expect(AgentInstallation.updateMany).toHaveBeenCalledWith( + { + agentName: 'openclaw', + instanceId: { $in: ['default', null] }, + status: 'active', + githubIssueWrite: { $exists: false }, + }, + { $set: { githubIssueWrite: true } }, + ); + }); +}); diff --git a/backend/models/AgentRegistry.ts b/backend/models/AgentRegistry.ts index e64c08dc02..4ff5e187e3 100644 --- a/backend/models/AgentRegistry.ts +++ b/backend/models/AgentRegistry.ts @@ -227,6 +227,9 @@ export interface IAgentInstallationRegistry extends Document { createdAt: Date; lastUsedAt?: Date; }>; + // Server-granted because GitHub issue writes use the instance credential. + // Never map this to the client-editable `scopes` array. + githubIssueWrite: boolean; createdAt: Date; updatedAt: Date; recordUsage(tokens?: number): Promise; @@ -235,8 +238,8 @@ export interface IAgentInstallationRegistry extends Document { export interface IAgentInstallationRegistryModel extends Model { getInstalledAgents(podId: Types.ObjectId): mongoose.Query; isInstalled(agentName: string, podId: Types.ObjectId, instanceId?: string): Promise; - install(agentName: string, podId: Types.ObjectId, options: { version: string; config?: Map; scopes?: string[]; installedBy: Types.ObjectId; instanceId?: string; displayName?: string }): Promise; - upsert(agentName: string, podId: Types.ObjectId, options: { version: string; config?: Map; scopes?: string[]; installedBy: Types.ObjectId; instanceId?: string; displayName?: string }): Promise; + install(agentName: string, podId: Types.ObjectId, options: { version: string; config?: Map; scopes?: string[]; githubIssueWrite?: boolean; installedBy: Types.ObjectId; instanceId?: string; displayName?: string }): Promise; + upsert(agentName: string, podId: Types.ObjectId, options: { version: string; config?: Map; scopes?: string[]; githubIssueWrite?: boolean; installedBy: Types.ObjectId; instanceId?: string; displayName?: string }): Promise; uninstall(agentName: string, podId: Types.ObjectId, instanceId?: string): Promise; } @@ -276,6 +279,7 @@ const AgentInstallationSchema = new Schema( lastUsedAt: Date, }, ], + githubIssueWrite: { type: Boolean, default: false }, }, { timestamps: true }, ); @@ -332,11 +336,14 @@ AgentInstallationSchema.statics.install = async function (agentName: string, pod version: string; config?: Map; scopes?: string[]; + githubIssueWrite?: boolean; installedBy: Types.ObjectId; instanceId?: string; displayName?: string; }) { - const { version, config, scopes, installedBy, instanceId = 'default', displayName } = options; + const { + version, config, scopes, githubIssueWrite, installedBy, instanceId = 'default', displayName, + } = options; const existing = await this.findOne({ agentName: agentName.toLowerCase(), podId, instanceId }); if (existing) { if (existing.status === 'active') throw new Error('Agent already installed'); @@ -344,9 +351,13 @@ AgentInstallationSchema.statics.install = async function (agentName: string, pod existing.version = version; existing.config = config; existing.scopes = scopes; + if (githubIssueWrite !== undefined) existing.githubIssueWrite = githubIssueWrite === true; return existing.save(); } - return this.create({ agentName: agentName.toLowerCase(), podId, instanceId, displayName, version, config, scopes, installedBy }); + return this.create({ + agentName: agentName.toLowerCase(), podId, instanceId, displayName, version, config, scopes, installedBy, + githubIssueWrite: githubIssueWrite === true, + }); }; /** @@ -365,11 +376,14 @@ AgentInstallationSchema.statics.upsert = async function (agentName: string, podI version: string; config?: Map; scopes?: string[]; + githubIssueWrite?: boolean; installedBy: Types.ObjectId; instanceId?: string; displayName?: string; }) { - const { version, config, scopes, installedBy, instanceId = 'default', displayName } = options; + const { + version, config, scopes, githubIssueWrite, installedBy, instanceId = 'default', displayName, + } = options; const filter = { agentName: agentName.toLowerCase(), podId, instanceId }; const update = { $setOnInsert: { agentName: agentName.toLowerCase(), podId, instanceId, installedBy, version, displayName }, @@ -380,6 +394,7 @@ AgentInstallationSchema.statics.upsert = async function (agentName: string, podI ...(declaredRuntimeIdentity(config) ? { runtimeIdentityAt: new Date() } : {}), ...(config ? { config } : {}), ...(scopes ? { scopes } : {}), + ...(githubIssueWrite !== undefined ? { githubIssueWrite: githubIssueWrite === true } : {}), status: 'active', }, }; diff --git a/backend/package.json b/backend/package.json index e9a22655d0..3fe65529af 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "start": "node dist/server.js", "dev": "nodemon --exec 'ts-node --transpile-only' server.ts", "migrate-files": "node migrations/migrateFilesToDB.js", + "migrate:github-issue-write-capability": "ts-node scripts/migrate-github-issue-write-capability.ts", "test": "jest", "test:temp-cleanup": "node scripts/verify-jest-temp-cleanup.js", "test:watch": "jest --watch", diff --git a/backend/routes/github.ts b/backend/routes/github.ts index 6f4d84f010..8fa20fcbd2 100644 --- a/backend/routes/github.ts +++ b/backend/routes/github.ts @@ -15,9 +15,13 @@ const githubIpRateLimit = rateLimit({ legacyHeaders: false, keyGenerator: cloudflareIpRateLimitKeyGenerator, }); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { agentCanWriteGitHubIssues } = require('../services/githubIssueWriteCapability'); interface AuthReq { user?: { role?: string }; + agentUser?: { _id?: unknown }; + agentInstallations?: Array<{ githubIssueWrite?: boolean }>; body?: Record; query?: Record; params?: Record; @@ -126,6 +130,19 @@ function mapGitHubUpstreamError( const router: ReturnType = express.Router(); +const requireGitHubIssueWriteCapability = (req: AuthReq, res: Res): boolean => { + // `anyAuth` also serves ordinary human JWTs. The capability is only for + // agent-runtime callers. `agentUser` is best-effort for legacy tokens, but + // both successful agent auth paths always attach the installation list. + const isAgentRuntimeCaller = Array.isArray(req.agentInstallations); + if (!isAgentRuntimeCaller || agentCanWriteGitHubIssues(req.agentInstallations)) return true; + res.status(403).json({ + error: 'GitHub issue writing is not granted to this agent installation', + code: 'github_issue_write_not_granted', + }); + return false; +}; + // REMOVED — `POST /token`, which handed our GitHub credential to callers. // // It was guarded by `agentRuntimeAuth` alone, so ANY `cm_agent_*` token — held @@ -187,6 +204,7 @@ router.get('/issues', githubIpRateLimit, dualAuth, async (req: AuthReq, res: Res router.post('/issues', githubIpRateLimit, dualAuth, async (req: AuthReq, res: Res) => { try { + if (!requireGitHubIssueWriteCapability(req, res)) return; if (!GitHubAppService.isPatConfigured() && !GitHubAppService.isConfigured()) { return res.status(503).json({ error: 'No GitHub credentials configured' }); } @@ -203,6 +221,7 @@ router.post('/issues', githubIpRateLimit, dualAuth, async (req: AuthReq, res: Re router.post('/issues/:number/comment', githubIpRateLimit, dualAuth, async (req: AuthReq, res: Res) => { try { + if (!requireGitHubIssueWriteCapability(req, res)) return; const issueNumber = Number(req.params?.number); const { body } = (req.body || {}) as { body?: string }; if (!body) return res.status(400).json({ error: 'body is required' }); @@ -217,6 +236,7 @@ router.post('/issues/:number/comment', githubIpRateLimit, dualAuth, async (req: router.post('/issues/:number/close', githubIpRateLimit, dualAuth, async (req: AuthReq, res: Res) => { try { + if (!requireGitHubIssueWriteCapability(req, res)) return; const issueNumber = Number(req.params?.number); const { comment } = (req.body || {}) as { comment?: string }; await GitHubAppService.closeIssue({ owner: PINNED_OWNER, repo: PINNED_REPO, issueNumber, comment }); diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts index 23a0c27d27..a9008e631b 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -24,6 +24,7 @@ const { AGENT_USERNAME_CONFLICT_CODE?: string; AGENT_IDENTITY_CONFLICT_CODE?: string; }; +const GlobalModelConfigService = require('../../services/globalModelConfigService'); const AgentMessageService = require('../../services/agentMessageService'); const { deriveAgentState, runInstructionFor } = require('../../services/agentStateService'); const FirstContactService = require('../../services/firstContactService'); @@ -43,6 +44,7 @@ const { buildAgentProfileId, composeInstallIntro, } = require('./helpers'); +const { isDevTierGitHubIssueWriter } = require('../../services/githubIssueWriteCapability'); const { AUTO_GRANTED_INTEGRATION_SCOPES, revokeRuntimeTokensAfterLastUninstall, @@ -628,6 +630,23 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), } } + // GitHub issue writes spend a server-held credential, so this is a + // server-owned per-installation capability rather than a caller-provided + // scope. The configured OpenClaw dev seats are its only initial grant. + // If configuration cannot be read, fail closed: the install still works, + // but it receives no GitHub write authority. + let githubIssueWrite = false; + try { + const modelConfig = await GlobalModelConfigService.getConfig(); + githubIssueWrite = isDevTierGitHubIssueWriter({ + instanceId: normalizedInstanceId, + runtimeType: effectiveRuntimeType, + devAgentIds: modelConfig?.openclaw?.devAgentIds, + }); + } catch (err) { + console.warn('[install] could not resolve dev-tier GitHub issue capability:', (err as Error).message); + } + const grantedScopes = Array.from(new Set([ ...requiredScopes, ...scopes, @@ -803,6 +822,7 @@ installRouter.post('/install', logAgentInstallRefusal('/api/registry/install'), version: version || agent.latestVersion, config: installConfig, scopes: grantedScopes, + githubIssueWrite, installedBy: userId, instanceId: normalizedInstanceId, displayName: effectiveDisplayName, diff --git a/backend/scripts/migrate-github-issue-write-capability.ts b/backend/scripts/migrate-github-issue-write-capability.ts new file mode 100644 index 0000000000..a6733230f6 --- /dev/null +++ b/backend/scripts/migrate-github-issue-write-capability.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/* + * Grant the GitHub issue-write capability to the established configured + * OpenClaw dev seats that predate TASK-023's default-off field. + * + * GitHub issue writes spend Commonly's server credential. The capability is + * therefore derived only from server-owned runtime/configuration state; it + * must never be inferred from installation.config or an agent request. + * + * This is deliberately a one-shot migration rather than runtime middleware: + * request authentication must stay read-only apart from token/last-active + * bookkeeping, otherwise every shared agent route inherits an unbounded + * database-write flow. New installations receive the same grant in + * routes/registry/install.ts. + * + * Only rows where githubIssueWrite is ABSENT are eligible. That makes the + * migration idempotent and, crucially, preserves a later explicit false + * value as a revocation rather than turning a rerun into a re-grant. + * + * Run: + * MONGO_URI=... npm run migrate:github-issue-write-capability -- --dry + * MONGO_URI=... npm run migrate:github-issue-write-capability + */ + +import mongoose from 'mongoose'; +import { AgentInstallation } from '../models/AgentRegistry'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { isConfiguredDevTierGitHubIssueWriter } = require('../services/githubIssueWriteCapability') as typeof import('../services/githubIssueWriteCapability'); + +interface InstallationIdentity { + agentName: string; + instanceId: string; + installationCount: number; +} + +export interface MigrationResult { + legacyInstallations: number; + identitiesChecked: number; + identitiesGranted: number; + installationsGranted: number; + skippedMalformed: number; + dryRun: boolean; +} + +const normalizeIdentityPart = (value: unknown): string => String(value || '').trim().toLowerCase(); + +/** + * Backfill only database rows created before githubIssueWrite existed. The + * server-owned resolver fails closed on a missing/invalid runtime config. + */ +export async function migrateGitHubIssueWriteCapability( + options: { dryRun?: boolean } = {}, +): Promise { + const dryRun = options.dryRun === true; + const result: MigrationResult = { + legacyInstallations: 0, + identitiesChecked: 0, + identitiesGranted: 0, + installationsGranted: 0, + skippedMalformed: 0, + dryRun, + }; + + const legacyInstallations = await AgentInstallation.find({ + status: 'active', + githubIssueWrite: { $exists: false }, + }).select('agentName instanceId').lean() as Array<{ agentName?: unknown; instanceId?: unknown }>; + + result.legacyInstallations = legacyInstallations.length; + const identities = new Map(); + for (const installation of legacyInstallations) { + const agentName = normalizeIdentityPart(installation.agentName); + const instanceId = normalizeIdentityPart(installation.instanceId) || 'default'; + if (!agentName) { + result.skippedMalformed += 1; + continue; + } + + const key = `${agentName}\u0000${instanceId}`; + const identity = identities.get(key); + if (identity) { + identity.installationCount += 1; + } else { + identities.set(key, { agentName, instanceId, installationCount: 1 }); + } + } + + for (const identity of identities.values()) { + result.identitiesChecked += 1; + const shouldGrant = await isConfiguredDevTierGitHubIssueWriter(identity); + if (!shouldGrant) continue; + + result.identitiesGranted += 1; + if (dryRun) { + result.installationsGranted += identity.installationCount; + continue; + } + + const update = await AgentInstallation.updateMany( + { + agentName: identity.agentName, + // Current rows always have the schema default, but include historical + // absent instanceId values in the canonical default identity so this + // one-shot repair does not strand the oldest install records. + instanceId: identity.instanceId === 'default' + ? { $in: ['default', null] } + : identity.instanceId, + status: 'active', + githubIssueWrite: { $exists: false }, + }, + { $set: { githubIssueWrite: true } }, + ); + result.installationsGranted += update.modifiedCount; + } + + return result; +} + +async function main(): Promise { + const mongoUri = process.env.MONGO_URI; + if (!mongoUri) { + console.error('MONGO_URI is required'); + process.exit(1); + } + + await mongoose.connect(mongoUri); + try { + const result = await migrateGitHubIssueWriteCapability({ + dryRun: process.argv.includes('--dry'), + }); + console.log(`[github-issue-write-capability] ${result.dryRun ? 'DRY-RUN' : 'APPLIED'}`); + console.log(` legacy installations : ${result.legacyInstallations}`); + console.log(` identities checked : ${result.identitiesChecked}`); + console.log(` identities granted : ${result.identitiesGranted}`); + console.log(` installs granted : ${result.installationsGranted}`); + console.log(` malformed skipped : ${result.skippedMalformed}`); + } finally { + await mongoose.disconnect(); + } +} + +if (require.main === module) { + main().catch((err) => { + console.error('GitHub issue-write capability migration failed:', err); + process.exit(1); + }); +} diff --git a/backend/services/githubIssueWriteCapability.ts b/backend/services/githubIssueWriteCapability.ts new file mode 100644 index 0000000000..92349f7604 --- /dev/null +++ b/backend/services/githubIssueWriteCapability.ts @@ -0,0 +1,74 @@ +// GitHub issue writes spend the server's repository credential. They are not +// an ordinary runtime scope: the browser can update installation.scopes, so a +// scope string would let any pod member grant this authority to their agent. +// Keep the grant server-owned and per installation instead. + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const AgentIdentityService = require('./agentIdentityService'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const GlobalModelConfigService = require('./globalModelConfigService'); + +export const isDevTierGitHubIssueWriter = ({ + instanceId, + runtimeType, + devAgentIds, +}: { + instanceId: unknown; + runtimeType: unknown; + devAgentIds: unknown; +}): boolean => { + const normalizedInstance = String(instanceId || '').trim().toLowerCase(); + const normalizedRuntime = String(runtimeType || '').trim().toLowerCase(); + const devSeats = Array.isArray(devAgentIds) + ? devAgentIds.map((id) => String(id || '').trim().toLowerCase()) + : []; + + return normalizedRuntime === 'moltbot' && devSeats.includes(normalizedInstance); +}; + +/** + * Resolve the server-owned initial grant for an already-existing agent + * identity. This is deliberately keyed by the canonical agent type rather + * than installation.config: the latter is user-editable, while an untrusted + * caller must never be able to promote its own installation by writing + * `runtimeType: 'moltbot'` into config. + * + * The caller persists a positive result on the installation row. A config + * read failure returns false, so rollout fails closed instead of turning a + * transient settings outage into a GitHub write grant. + */ +export const isConfiguredDevTierGitHubIssueWriter = async ({ + agentName, + instanceId, +}: { + agentName: unknown; + instanceId: unknown; +}): Promise => { + const runtimeType = AgentIdentityService + .getAgentTypeConfig(String(agentName || ''))?.runtime; + if (String(runtimeType || '').trim().toLowerCase() !== 'moltbot') return false; + + try { + const modelConfig = await GlobalModelConfigService.getConfig(); + return isDevTierGitHubIssueWriter({ + instanceId, + runtimeType, + devAgentIds: modelConfig?.openclaw?.devAgentIds, + }); + } catch (err) { + console.warn('[github-issue-write] could not resolve dev-tier capability:', (err as Error).message); + return false; + } +}; + +export const agentCanWriteGitHubIssues = (installations: unknown): boolean => ( + Array.isArray(installations) + && installations.some((installation) => ( + installation && typeof installation === 'object' + && (installation as { githubIssueWrite?: unknown }).githubIssueWrite === true + )) +); + +// CJS compat: let require() return named exports without a .default hop. +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports; diff --git a/docs-site/agents/runtime-protocol.mdx b/docs-site/agents/runtime-protocol.mdx index 16498b50b1..96fe60c1b1 100644 --- a/docs-site/agents/runtime-protocol.mdx +++ b/docs-site/agents/runtime-protocol.mdx @@ -127,6 +127,8 @@ With a runtime token, an installed agent can use the runtime surface to: | Read or write agent memory | `GET/PUT /api/agents/runtime/memory` | | Sync memory sections | `POST /api/agents/runtime/memory/sync` | | Manage pod tasks | `/api/v1/tasks/:podId` | +| List GitHub issues | `GET /api/github/issues` | +| Create GitHub issue | `POST /api/github/issues` (server-granted dev-seat capability required) | The MCP server maps these operations into `commonly_*` tools. An HTTP agent can use the same endpoints without adopting the MCP protocol. diff --git a/docs-site/integrations/github.mdx b/docs-site/integrations/github.mdx index b251cd189d..3fc4afa0cf 100644 --- a/docs-site/integrations/github.mdx +++ b/docs-site/integrations/github.mdx @@ -4,3 +4,5 @@ description: Install the GitHub App for this instance --- The instance administrator installs the GitHub App and creates the connection. + +Agents can always list issues. Creating, commenting on or closing an issue also needs a GitHub-write capability on the agent's installation, which is off by default. Human API callers are not affected.