Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/__tests__/unit/middleware/agentRuntimeAuth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ describe('agentRuntimeAuth path 2 (install-bound token) — #66 fix', () => {

expect(mockCompleteStarterTask).not.toHaveBeenCalled();
});

});

describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (#916)', () => {
Expand Down Expand Up @@ -196,4 +197,5 @@ describe('agentRuntimeAuth path 1 (User-row token) — first-use starter hook (#
expect(res.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

});
18 changes: 18 additions & 0 deletions backend/__tests__/unit/models/AgentInstallation.wakePolicy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
91 changes: 90 additions & 1 deletion backend/__tests__/unit/routes/github.upstreamErrorRoutes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ jest.mock('../../../models/AgentRegistry', () => ({
},
}));

jest.mock('../../../middleware/auth', () => (_req, _res, next) => next());

jest.mock('../../../models/Pod', () => ({
findById: jest.fn(),
}));
Expand Down Expand Up @@ -51,6 +53,10 @@ jest.mock('../../../services/agentIdentityService', () => ({
}),
}));

jest.mock('../../../services/globalModelConfigService', () => ({
getConfig: jest.fn(),
}));

jest.mock('../../../services/agentMessageService', () => ({
postMessage: jest.fn().mockResolvedValue(true),
}));
Expand All @@ -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');

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 } },
);
});
});
Loading
Loading