From 6f0e44b82aa0627217ad2725757b7e2b0c860c28 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:32:16 -0700 Subject: [PATCH 1/4] fix(github): gate agent issue writes by capability --- .../AgentInstallation.wakePolicy.test.js | 18 ++++ .../routes/github.upstreamErrorRoutes.test.js | 91 ++++++++++++++++++- .../registry.install-runtime-type.test.js | 69 ++++++++++++++ backend/models/AgentRegistry.ts | 25 ++++- backend/routes/github.ts | 20 ++++ backend/routes/registry/install.ts | 20 ++++ .../services/githubIssueWriteCapability.ts | 34 +++++++ docs-site/agents/overview.mdx | 2 +- docs-site/integrations/github.mdx | 5 + 9 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 backend/services/githubIssueWriteCapability.ts diff --git a/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js b/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js index ebdb432f9..ba84f62d4 100644 --- a/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js +++ b/backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js @@ -87,4 +87,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 b72ed70d2..a9308f47c 100644 --- a/backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js +++ b/backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js @@ -13,8 +13,11 @@ * configuration and signs locally; it must keep its honest local 500. */ +let mockAgentInstallations = []; +let mockAgentUser = { _id: 'bot-1' }; jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => { - req.agentUser = { _id: 'bot-1' }; + if (mockAgentUser) req.agentUser = mockAgentUser; + req.agentInstallations = mockAgentInstallations; next(); }); @@ -119,6 +122,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); }); @@ -150,6 +154,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 d07e58e0c..89ea8c132 100644 --- a/backend/__tests__/unit/routes/registry.install-runtime-type.test.js +++ b/backend/__tests__/unit/routes/registry.install-runtime-type.test.js @@ -10,6 +10,8 @@ jest.mock('../../../models/AgentRegistry', () => ({ }, })); +jest.mock('../../../middleware/auth', () => (_req, _res, next) => next()); + jest.mock('../../../models/Pod', () => ({ findById: jest.fn(), })); @@ -51,6 +53,10 @@ jest.mock('../../../services/agentIdentityService', () => ({ }), })); +jest.mock('../../../services/globalModelConfigService', () => ({ + getConfig: jest.fn(), +})); + jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue(true), })); @@ -66,6 +72,7 @@ const AgentProfile = require('../../../models/AgentProfile'); const AgentTemplate = require('../../../models/AgentTemplate'); const Activity = require('../../../models/Activity'); const AgentIdentityService = require('../../../services/agentIdentityService'); +const GlobalModelConfigService = require('../../../services/globalModelConfigService'); const FirstContactService = require('../../../services/firstContactService'); const installRouter = require('../../../routes/registry/install'); @@ -123,6 +130,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({ @@ -222,6 +230,67 @@ describe('registry install runtimeType fallback', () => { expect(res.status).not.toHaveBeenCalledWith(500); }); + 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/models/AgentRegistry.ts b/backend/models/AgentRegistry.ts index 2dd933d51..4641cc914 100644 --- a/backend/models/AgentRegistry.ts +++ b/backend/models/AgentRegistry.ts @@ -212,6 +212,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; @@ -220,8 +223,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; } @@ -251,6 +254,7 @@ const AgentInstallationSchema = new Schema( lastUsedAt: Date, }, ], + githubIssueWrite: { type: Boolean, default: false }, }, { timestamps: true }, ); @@ -275,11 +279,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'); @@ -287,9 +294,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, + }); }; /** @@ -308,17 +319,21 @@ 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 }, $set: { ...(config ? { config } : {}), ...(scopes ? { scopes } : {}), + ...(githubIssueWrite !== undefined ? { githubIssueWrite: githubIssueWrite === true } : {}), status: 'active', }, }; diff --git a/backend/routes/github.ts b/backend/routes/github.ts index fa031c279..60c83728c 100644 --- a/backend/routes/github.ts +++ b/backend/routes/github.ts @@ -6,9 +6,13 @@ const agentRuntimeAuth = require('../middleware/agentRuntimeAuth'); const auth = require('../middleware/auth'); // eslint-disable-next-line global-require const GitHubAppService = require('../services/githubAppService'); +// 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; @@ -123,6 +127,19 @@ function anyAuth(req: AuthReq, res: Res, next: () => void) { 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 @@ -171,6 +188,7 @@ router.get('/issues', anyAuth, async (req: AuthReq, res: Res) => { router.post('/issues', anyAuth, 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' }); } @@ -187,6 +205,7 @@ router.post('/issues', anyAuth, async (req: AuthReq, res: Res) => { router.post('/issues/:number/comment', anyAuth, 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' }); @@ -201,6 +220,7 @@ router.post('/issues/:number/comment', anyAuth, async (req: AuthReq, res: Res) = router.post('/issues/:number/close', anyAuth, 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 6c4adbb0d..4eee30b54 100644 --- a/backend/routes/registry/install.ts +++ b/backend/routes/registry/install.ts @@ -10,6 +10,7 @@ const Activity = require('../../models/Activity'); const Pod = require('../../models/Pod'); const User = require('../../models/User'); const AgentIdentityService = require('../../services/agentIdentityService'); +const GlobalModelConfigService = require('../../services/globalModelConfigService'); const AgentMessageService = require('../../services/agentMessageService'); const { deriveAgentState } = require('../../services/agentStateService'); const FirstContactService = require('../../services/firstContactService'); @@ -25,6 +26,7 @@ const { buildAgentProfileId, composeInstallIntro, } = require('./helpers'); +const { isDevTierGitHubIssueWriter } = require('../../services/githubIssueWriteCapability'); const { AUTO_GRANTED_INTEGRATION_SCOPES, } = require('./tokens'); @@ -381,6 +383,23 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any } } + // 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, @@ -433,6 +452,7 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any version: version || agent.latestVersion, config: installConfig, scopes: grantedScopes, + githubIssueWrite, installedBy: userId, instanceId: normalizedInstanceId, displayName: effectiveDisplayName, diff --git a/backend/services/githubIssueWriteCapability.ts b/backend/services/githubIssueWriteCapability.ts new file mode 100644 index 000000000..4e22ce476 --- /dev/null +++ b/backend/services/githubIssueWriteCapability.ts @@ -0,0 +1,34 @@ +// 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. + +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); +}; + +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/overview.mdx b/docs-site/agents/overview.mdx index 08d07ce7c..172052dcd 100644 --- a/docs-site/agents/overview.mdx +++ b/docs-site/agents/overview.mdx @@ -37,7 +37,7 @@ Once connected with a runtime token (`cm_agent_*`), agents have access to: | Poll events | `GET /api/agents/runtime/events` | | Acknowledge event | `POST /api/agents/runtime/events/:id/ack` | | List GitHub issues | `GET /api/github/issues` | -| Create GitHub issue | `POST /api/github/issues` | +| Create GitHub issue | `POST /api/github/issues` (server-granted dev-seat capability required) | ## Supported runtimes diff --git a/docs-site/integrations/github.mdx b/docs-site/integrations/github.mdx index 790aef011..570c06302 100644 --- a/docs-site/integrations/github.mdx +++ b/docs-site/integrations/github.mdx @@ -22,6 +22,11 @@ Use the GitHub API endpoints or agent tools directly when a workflow needs an issue action. An event handler can connect that action to another surface when the integration is explicitly installed and configured for it. +Human API callers can use the issue-write endpoints. Runtime-token agents can +always list issues, but creating, commenting on, or closing an issue requires a +server-granted GitHub-write capability on their installation. The capability is +off by default and is initially granted only to configured development seats. + ## API endpoints ```bash From 92d5e160eeefb2f2eaa74bc157aea8d4230c35af Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:55:51 -0700 Subject: [PATCH 2/4] fix(github): backfill dev issue-write capability --- .../unit/middleware/agentRuntimeAuth.test.js | 126 ++++++++++++++++++ backend/middleware/agentRuntimeAuth.ts | 53 ++++++++ .../services/githubIssueWriteCapability.ts | 40 ++++++ 3 files changed, 219 insertions(+) diff --git a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js index fec548243..aff92c11c 100644 --- a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js +++ b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js @@ -18,8 +18,13 @@ const mockUserUpdateOne = jest.fn(); const mockInstallationFindOne = jest.fn(); const mockInstallationFind = jest.fn(); const mockInstallationUpdateOne = jest.fn(); +const mockInstallationUpdateMany = jest.fn(); const mockCompleteStarterTask = jest.fn(); +jest.mock('../../../services/globalModelConfigService', () => ({ + getConfig: jest.fn(), +})); + jest.mock('../../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: (...args) => mockCompleteStarterTask(...args), })); @@ -45,6 +50,7 @@ jest.mock('../../../models/AgentRegistry', () => ({ findOne: (...args) => mockInstallationFindOne(...args), find: (...args) => mockInstallationFind(...args), updateOne: (...args) => mockInstallationUpdateOne(...args), + updateMany: (...args) => mockInstallationUpdateMany(...args), }, })); jest.mock('../../../models/Pod', () => ({ @@ -52,6 +58,7 @@ jest.mock('../../../models/Pod', () => ({ })); const agentRuntimeAuth = require('../../../middleware/agentRuntimeAuth').default; +const GlobalModelConfigService = require('../../../services/globalModelConfigService'); const buildReq = (token) => { const headers = { authorization: `Bearer ${token}` }; @@ -76,6 +83,10 @@ beforeEach(() => { mockInstallationFindOne.mockReset(); mockInstallationFind.mockReset(); mockInstallationUpdateOne.mockReset(); + mockInstallationUpdateMany.mockReset(); + mockInstallationUpdateMany.mockResolvedValue({ modifiedCount: 0 }); + GlobalModelConfigService.getConfig.mockReset(); + GlobalModelConfigService.getConfig.mockResolvedValue({ openclaw: { devAgentIds: ['theo'] } }); mockCompleteStarterTask.mockReset(); mockCompleteStarterTask.mockResolvedValue(undefined); }); @@ -140,6 +151,38 @@ describe('agentRuntimeAuth path 2 (install-bound token) — #66 fix', () => { expect(mockCompleteStarterTask).not.toHaveBeenCalled(); }); + + test('backfills a configured dev seat on the legacy installation-token path too', async () => { + mockUserFindOne.mockResolvedValue(null); + const matchedInstall = { + _id: 'install-theo', + agentName: 'openclaw', + instanceId: 'theo', + podId: { toString: () => 'pod-workspace' }, + githubIssueWrite: false, + runtimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], + }; + mockInstallationFindOne.mockResolvedValue(matchedInstall); + mockInstallationFind.mockResolvedValue([matchedInstall]); + mockInstallationUpdateOne.mockResolvedValue({}); + + const req = buildReq('cm_agent_legacy_dev'); + await agentRuntimeAuth(req, buildRes(), jest.fn()); + + expect(mockInstallationUpdateMany).toHaveBeenCalledWith( + { + agentName: 'openclaw', + instanceId: 'theo', + status: 'active', + githubIssueWrite: { $ne: true }, + }, + { $set: { githubIssueWrite: true } }, + ); + expect(req.agentUser).toBeUndefined(); + expect(req.agentInstallations).toEqual([ + expect.objectContaining({ githubIssueWrite: true }), + ]); + }); }); describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (#916)', () => { @@ -196,4 +239,87 @@ describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (# expect(res.status).toHaveBeenCalledWith(401); expect(next).not.toHaveBeenCalled(); }); + + test('backfills the server-owned GitHub write grant for an existing configured dev seat before routes inspect it', async () => { + mockUserFindOne.mockResolvedValue({ + _id: 'bot-user-1', + username: 'openclaw-theo', + isBot: true, + botMetadata: { agentName: 'openclaw', instanceId: 'theo' }, + agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], + }); + mockInstallationFind.mockReturnValue({ + lean: async () => [{ + _id: 'install-theo', + podId: { toString: () => 'pod-workspace' }, + githubIssueWrite: false, + }], + }); + + const req = buildReq('cm_agent_dev'); + await agentRuntimeAuth(req, buildRes(), jest.fn()); + + expect(mockInstallationUpdateMany).toHaveBeenCalledWith( + { + agentName: 'openclaw', + instanceId: 'theo', + status: 'active', + githubIssueWrite: { $ne: true }, + }, + { $set: { githubIssueWrite: true } }, + ); + expect(req.agentInstallations).toEqual([ + expect.objectContaining({ githubIssueWrite: true }), + ]); + }); + + test('does not backfill GitHub write access for a non-dev OpenClaw seat', async () => { + mockUserFindOne.mockResolvedValue({ + _id: 'bot-user-1', + username: 'openclaw-community', + isBot: true, + botMetadata: { agentName: 'openclaw', instanceId: 'community' }, + agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], + }); + mockInstallationFind.mockReturnValue({ + lean: async () => [{ + _id: 'install-community', + podId: { toString: () => 'pod-workspace' }, + githubIssueWrite: false, + }], + }); + + const req = buildReq('cm_agent_community'); + await agentRuntimeAuth(req, buildRes(), jest.fn()); + + expect(mockInstallationUpdateMany).not.toHaveBeenCalled(); + expect(req.agentInstallations).toEqual([ + expect.objectContaining({ githubIssueWrite: false }), + ]); + }); + + test('does not backfill GitHub write access for a non-OpenClaw identity using a dev-seat label', async () => { + mockUserFindOne.mockResolvedValue({ + _id: 'bot-user-1', + username: 'codex-theo', + isBot: true, + botMetadata: { agentName: 'codex', instanceId: 'theo' }, + agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], + }); + mockInstallationFind.mockReturnValue({ + lean: async () => [{ + _id: 'install-codex-theo', + podId: { toString: () => 'pod-workspace' }, + githubIssueWrite: false, + }], + }); + + const req = buildReq('cm_agent_non_openclaw'); + await agentRuntimeAuth(req, buildRes(), jest.fn()); + + expect(mockInstallationUpdateMany).not.toHaveBeenCalled(); + expect(req.agentInstallations).toEqual([ + expect.objectContaining({ githubIssueWrite: false }), + ]); + }); }); diff --git a/backend/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 2b7c188f9..41174b1bf 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -7,6 +7,8 @@ import User, { IUser } from '../models/User'; import { touchLastActive } from './auth'; import Pod from '../models/Pod'; import AgentCredential from '../models/AgentCredential'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { isConfiguredDevTierGitHubIssueWriter } = require('../services/githubIssueWriteCapability') as typeof import('../services/githubIssueWriteCapability'); // eslint-disable-next-line global-require const { hash } = require('../utils/secret') as { hash: (value: string) => string }; @@ -50,6 +52,51 @@ const extractToken = (req: Request): string | undefined => { return req.header('x-commonly-agent-token'); }; +/** + * #1322 added a default-off capability after the established OpenClaw seats + * already had active AgentInstallation rows. New installs receive the grant + * in the install route, but the runtime-auth boundary is the one place every + * existing seat must cross before it can spend the GitHub credential. + * + * Reconcile there, before a route sees req.agentInstallations. That turns the + * first authenticated request after deploy into a safe, server-derived + * backfill for every active installation of a configured dev identity. It + * avoids relying on a reinstall/reprovision and avoids trusting the + * user-editable installation config. Failed reconciliation leaves the field + * false, so the subsequent write gate remains fail-closed. + */ +const backfillDevTierGitHubIssueWrite = async ( + agentName: string, + instanceId: string, + installations: T[], +): Promise => { + if (!installations.length || installations.some((installation) => installation?.githubIssueWrite === true)) { + return installations; + } + + const shouldGrant = await isConfiguredDevTierGitHubIssueWriter({ agentName, instanceId }); + if (!shouldGrant) return installations; + + try { + await AgentInstallation.updateMany( + { + agentName, + instanceId, + status: 'active', + githubIssueWrite: { $ne: true }, + }, + { $set: { githubIssueWrite: true } }, + ); + installations.forEach((installation) => { + installation.githubIssueWrite = true; + }); + } catch (err) { + console.warn('[agent-auth] could not backfill GitHub issue write capability:', (err as Error).message); + } + + return installations; +}; + export default async function agentRuntimeAuth(req: Request, res: Response, next: NextFunction): Promise { try { const token = extractToken(req); @@ -113,6 +160,7 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next instanceId, status: 'active', }).lean(); + await backfillDevTierGitHubIssueWrite(agentName, instanceId, installations); const installationPodIds = installations .map((inst) => inst?.podId?.toString()) .filter(Boolean) as string[]; @@ -178,6 +226,11 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next instanceId: installation.instanceId || 'default', status: 'active', }); + await backfillDevTierGitHubIssueWrite( + installation.agentName, + installation.instanceId || 'default', + allActiveInstallations, + ); req.agentInstallation = installation as never; req.agentInstallations = allActiveInstallations as never[]; diff --git a/backend/services/githubIssueWriteCapability.ts b/backend/services/githubIssueWriteCapability.ts index 4e22ce476..92349f760 100644 --- a/backend/services/githubIssueWriteCapability.ts +++ b/backend/services/githubIssueWriteCapability.ts @@ -3,6 +3,11 @@ // 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, @@ -21,6 +26,41 @@ export const isDevTierGitHubIssueWriter = ({ 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) => ( From b885b12fcb8d917ccd57715096deb954f2b4ef0f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:10:33 -0700 Subject: [PATCH 3/4] fix(github): move issue capability backfill out of auth --- .../unit/middleware/agentRuntimeAuth.test.js | 124 --------------- ...rate-github-issue-write-capability.test.js | 120 ++++++++++++++ backend/middleware/agentRuntimeAuth.ts | 53 ------- .../migrate-github-issue-write-capability.ts | 147 ++++++++++++++++++ 4 files changed, 267 insertions(+), 177 deletions(-) create mode 100644 backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js create mode 100644 backend/scripts/migrate-github-issue-write-capability.ts diff --git a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js index aff92c11c..6e9af7bba 100644 --- a/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js +++ b/backend/__tests__/unit/middleware/agentRuntimeAuth.test.js @@ -18,13 +18,8 @@ const mockUserUpdateOne = jest.fn(); const mockInstallationFindOne = jest.fn(); const mockInstallationFind = jest.fn(); const mockInstallationUpdateOne = jest.fn(); -const mockInstallationUpdateMany = jest.fn(); const mockCompleteStarterTask = jest.fn(); -jest.mock('../../../services/globalModelConfigService', () => ({ - getConfig: jest.fn(), -})); - jest.mock('../../../services/starterTaskService', () => ({ completeConnectAgentStarterTask: (...args) => mockCompleteStarterTask(...args), })); @@ -50,7 +45,6 @@ jest.mock('../../../models/AgentRegistry', () => ({ findOne: (...args) => mockInstallationFindOne(...args), find: (...args) => mockInstallationFind(...args), updateOne: (...args) => mockInstallationUpdateOne(...args), - updateMany: (...args) => mockInstallationUpdateMany(...args), }, })); jest.mock('../../../models/Pod', () => ({ @@ -58,7 +52,6 @@ jest.mock('../../../models/Pod', () => ({ })); const agentRuntimeAuth = require('../../../middleware/agentRuntimeAuth').default; -const GlobalModelConfigService = require('../../../services/globalModelConfigService'); const buildReq = (token) => { const headers = { authorization: `Bearer ${token}` }; @@ -83,10 +76,6 @@ beforeEach(() => { mockInstallationFindOne.mockReset(); mockInstallationFind.mockReset(); mockInstallationUpdateOne.mockReset(); - mockInstallationUpdateMany.mockReset(); - mockInstallationUpdateMany.mockResolvedValue({ modifiedCount: 0 }); - GlobalModelConfigService.getConfig.mockReset(); - GlobalModelConfigService.getConfig.mockResolvedValue({ openclaw: { devAgentIds: ['theo'] } }); mockCompleteStarterTask.mockReset(); mockCompleteStarterTask.mockResolvedValue(undefined); }); @@ -152,37 +141,6 @@ describe('agentRuntimeAuth path 2 (install-bound token) — #66 fix', () => { expect(mockCompleteStarterTask).not.toHaveBeenCalled(); }); - test('backfills a configured dev seat on the legacy installation-token path too', async () => { - mockUserFindOne.mockResolvedValue(null); - const matchedInstall = { - _id: 'install-theo', - agentName: 'openclaw', - instanceId: 'theo', - podId: { toString: () => 'pod-workspace' }, - githubIssueWrite: false, - runtimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], - }; - mockInstallationFindOne.mockResolvedValue(matchedInstall); - mockInstallationFind.mockResolvedValue([matchedInstall]); - mockInstallationUpdateOne.mockResolvedValue({}); - - const req = buildReq('cm_agent_legacy_dev'); - await agentRuntimeAuth(req, buildRes(), jest.fn()); - - expect(mockInstallationUpdateMany).toHaveBeenCalledWith( - { - agentName: 'openclaw', - instanceId: 'theo', - status: 'active', - githubIssueWrite: { $ne: true }, - }, - { $set: { githubIssueWrite: true } }, - ); - expect(req.agentUser).toBeUndefined(); - expect(req.agentInstallations).toEqual([ - expect.objectContaining({ githubIssueWrite: true }), - ]); - }); }); describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (#916)', () => { @@ -240,86 +198,4 @@ describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (# expect(next).not.toHaveBeenCalled(); }); - test('backfills the server-owned GitHub write grant for an existing configured dev seat before routes inspect it', async () => { - mockUserFindOne.mockResolvedValue({ - _id: 'bot-user-1', - username: 'openclaw-theo', - isBot: true, - botMetadata: { agentName: 'openclaw', instanceId: 'theo' }, - agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], - }); - mockInstallationFind.mockReturnValue({ - lean: async () => [{ - _id: 'install-theo', - podId: { toString: () => 'pod-workspace' }, - githubIssueWrite: false, - }], - }); - - const req = buildReq('cm_agent_dev'); - await agentRuntimeAuth(req, buildRes(), jest.fn()); - - expect(mockInstallationUpdateMany).toHaveBeenCalledWith( - { - agentName: 'openclaw', - instanceId: 'theo', - status: 'active', - githubIssueWrite: { $ne: true }, - }, - { $set: { githubIssueWrite: true } }, - ); - expect(req.agentInstallations).toEqual([ - expect.objectContaining({ githubIssueWrite: true }), - ]); - }); - - test('does not backfill GitHub write access for a non-dev OpenClaw seat', async () => { - mockUserFindOne.mockResolvedValue({ - _id: 'bot-user-1', - username: 'openclaw-community', - isBot: true, - botMetadata: { agentName: 'openclaw', instanceId: 'community' }, - agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], - }); - mockInstallationFind.mockReturnValue({ - lean: async () => [{ - _id: 'install-community', - podId: { toString: () => 'pod-workspace' }, - githubIssueWrite: false, - }], - }); - - const req = buildReq('cm_agent_community'); - await agentRuntimeAuth(req, buildRes(), jest.fn()); - - expect(mockInstallationUpdateMany).not.toHaveBeenCalled(); - expect(req.agentInstallations).toEqual([ - expect.objectContaining({ githubIssueWrite: false }), - ]); - }); - - test('does not backfill GitHub write access for a non-OpenClaw identity using a dev-seat label', async () => { - mockUserFindOne.mockResolvedValue({ - _id: 'bot-user-1', - username: 'codex-theo', - isBot: true, - botMetadata: { agentName: 'codex', instanceId: 'theo' }, - agentRuntimeTokens: [{ tokenHash: 'hashed-token', lastUsedAt: new Date('2026-08-01T00:00:00Z') }], - }); - mockInstallationFind.mockReturnValue({ - lean: async () => [{ - _id: 'install-codex-theo', - podId: { toString: () => 'pod-workspace' }, - githubIssueWrite: false, - }], - }); - - const req = buildReq('cm_agent_non_openclaw'); - await agentRuntimeAuth(req, buildRes(), jest.fn()); - - expect(mockInstallationUpdateMany).not.toHaveBeenCalled(); - expect(req.agentInstallations).toEqual([ - expect.objectContaining({ githubIssueWrite: false }), - ]); - }); }); 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 000000000..d57b6ea06 --- /dev/null +++ b/backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js @@ -0,0 +1,120 @@ +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 { + 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('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/middleware/agentRuntimeAuth.ts b/backend/middleware/agentRuntimeAuth.ts index 41174b1bf..2b7c188f9 100644 --- a/backend/middleware/agentRuntimeAuth.ts +++ b/backend/middleware/agentRuntimeAuth.ts @@ -7,8 +7,6 @@ import User, { IUser } from '../models/User'; import { touchLastActive } from './auth'; import Pod from '../models/Pod'; import AgentCredential from '../models/AgentCredential'; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { isConfiguredDevTierGitHubIssueWriter } = require('../services/githubIssueWriteCapability') as typeof import('../services/githubIssueWriteCapability'); // eslint-disable-next-line global-require const { hash } = require('../utils/secret') as { hash: (value: string) => string }; @@ -52,51 +50,6 @@ const extractToken = (req: Request): string | undefined => { return req.header('x-commonly-agent-token'); }; -/** - * #1322 added a default-off capability after the established OpenClaw seats - * already had active AgentInstallation rows. New installs receive the grant - * in the install route, but the runtime-auth boundary is the one place every - * existing seat must cross before it can spend the GitHub credential. - * - * Reconcile there, before a route sees req.agentInstallations. That turns the - * first authenticated request after deploy into a safe, server-derived - * backfill for every active installation of a configured dev identity. It - * avoids relying on a reinstall/reprovision and avoids trusting the - * user-editable installation config. Failed reconciliation leaves the field - * false, so the subsequent write gate remains fail-closed. - */ -const backfillDevTierGitHubIssueWrite = async ( - agentName: string, - instanceId: string, - installations: T[], -): Promise => { - if (!installations.length || installations.some((installation) => installation?.githubIssueWrite === true)) { - return installations; - } - - const shouldGrant = await isConfiguredDevTierGitHubIssueWriter({ agentName, instanceId }); - if (!shouldGrant) return installations; - - try { - await AgentInstallation.updateMany( - { - agentName, - instanceId, - status: 'active', - githubIssueWrite: { $ne: true }, - }, - { $set: { githubIssueWrite: true } }, - ); - installations.forEach((installation) => { - installation.githubIssueWrite = true; - }); - } catch (err) { - console.warn('[agent-auth] could not backfill GitHub issue write capability:', (err as Error).message); - } - - return installations; -}; - export default async function agentRuntimeAuth(req: Request, res: Response, next: NextFunction): Promise { try { const token = extractToken(req); @@ -160,7 +113,6 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next instanceId, status: 'active', }).lean(); - await backfillDevTierGitHubIssueWrite(agentName, instanceId, installations); const installationPodIds = installations .map((inst) => inst?.podId?.toString()) .filter(Boolean) as string[]; @@ -226,11 +178,6 @@ export default async function agentRuntimeAuth(req: Request, res: Response, next instanceId: installation.instanceId || 'default', status: 'active', }); - await backfillDevTierGitHubIssueWrite( - installation.agentName, - installation.instanceId || 'default', - allActiveInstallations, - ); req.agentInstallation = installation as never; req.agentInstallations = allActiveInstallations as never[]; 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 000000000..1ba2c25fa --- /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=... node --import tsx backend/scripts/migrate-github-issue-write-capability.ts --dry + * MONGO_URI=... node --import tsx backend/scripts/migrate-github-issue-write-capability.ts + */ + +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); + }); +} From 91c250a95ad64e6ef34496c5730e31d2d4f9d143 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:16:42 -0700 Subject: [PATCH 4/4] chore(github): expose issue capability migration --- .../scripts/migrate-github-issue-write-capability.test.js | 6 ++++++ backend/package.json | 1 + backend/scripts/migrate-github-issue-write-capability.ts | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) 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 index d57b6ea06..dbf1b0348 100644 --- a/backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js +++ b/backend/__tests__/unit/scripts/migrate-github-issue-write-capability.test.js @@ -10,6 +10,7 @@ jest.mock('../../../services/githubIssueWriteCapability', () => ({ const { AgentInstallation } = require('../../../models/AgentRegistry'); const { isConfiguredDevTierGitHubIssueWriter } = require('../../../services/githubIssueWriteCapability'); +const backendPackage = require('../../../package.json'); const { migrateGitHubIssueWriteCapability, } = require('../../../scripts/migrate-github-issue-write-capability'); @@ -26,6 +27,11 @@ describe('migrateGitHubIssueWriteCapability', () => { 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' }, diff --git a/backend/package.json b/backend/package.json index da8264d9d..c12797ff9 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:watch": "jest --watch", "test:coverage": "jest --coverage", diff --git a/backend/scripts/migrate-github-issue-write-capability.ts b/backend/scripts/migrate-github-issue-write-capability.ts index 1ba2c25fa..a6733230f 100644 --- a/backend/scripts/migrate-github-issue-write-capability.ts +++ b/backend/scripts/migrate-github-issue-write-capability.ts @@ -18,8 +18,8 @@ * value as a revocation rather than turning a rerun into a re-grant. * * Run: - * MONGO_URI=... node --import tsx backend/scripts/migrate-github-issue-write-capability.ts --dry - * MONGO_URI=... node --import tsx backend/scripts/migrate-github-issue-write-capability.ts + * MONGO_URI=... npm run migrate:github-issue-write-capability -- --dry + * MONGO_URI=... npm run migrate:github-issue-write-capability */ import mongoose from 'mongoose';