Skip to content
Draft
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
3 changes: 2 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ WORKDIR /app
# than a claim of sameness, and `officecli --version` in each container is the
# check that keeps it honest.
ARG OPENCLAW_OFFICECLI_VERSION=1.0.70
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && \
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates git && \
ARCH="$(uname -m)" && \
case "$ARCH" in \
x86_64) ASSET="officecli-linux-x64" ;; \
Expand All @@ -47,6 +47,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certifi
rm -f /tmp/officecli-SHA256SUMS && \
chmod +x /usr/local/bin/officecli && \
officecli --version && \
git --version && \
apt-get clean && rm -rf /var/lib/apt/lists/*

COPY package*.json ./
Expand Down
106 changes: 106 additions & 0 deletions backend/__tests__/service/toolCallProviderRef.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/** Exercise the provider audit columns through the real PostgreSQL model. */
const { Pool } = require('pg');
// eslint-disable-next-line import/no-unresolved, import/extensions
const ToolCall = require('../../models/ToolCall');
// eslint-disable-next-line import/no-unresolved, import/extensions
const { TOOL_CALL_DDL } = require('../../models/ToolCall');

const run = process.env.INTEGRATION_TEST === 'true' ? describe : describe.skip;
const GRANT_ID = `task284-provider-ref-${Date.now()}`;

const observerPool = () => new Pool({
host: process.env.PG_HOST,
port: Number(process.env.PG_PORT || 5432),
database: process.env.PG_DATABASE,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
ssl: false,
});

run('ToolCall provider ref audit on real PostgreSQL', () => {
let observer;

beforeAll(async () => {
observer = observerPool();
await ToolCall.create({
callId: GRANT_ID,
grantId: GRANT_ID,
agentUserId: 'seat-a',
tool: 'github.push_branch',
argsDigest: 'a'.repeat(64),
outcome: 'ok',
providerRepo: 'Team-Commonly/commonly',
providerRef: 'refs/heads/seat/seat-a/work',
});
});

afterAll(async () => {
if (observer) {
await observer.query('DELETE FROM tool_calls WHERE grant_id = $1', [GRANT_ID]);
await observer.end();
}
// eslint-disable-next-line global-require, import/no-unresolved, import/extensions
const { pool } = require('../../config/db-pg');
if (pool?.end) await pool.end();
});

test('insert and list retain the exact provider repository and ref', async () => {
const query = 'SELECT provider_repo, provider_ref FROM tool_calls WHERE call_id = $1';
const { rows } = await observer.query(query, [GRANT_ID]);
expect(rows).toEqual([{
provider_repo: 'Team-Commonly/commonly',
provider_ref: 'refs/heads/seat/seat-a/work',
}]);
const [line] = await ToolCall.listForGrant(GRANT_ID);
expect(line).toMatchObject({
providerRepo: 'Team-Commonly/commonly',
providerRef: 'refs/heads/seat/seat-a/work',
});
});

test('DDL creates the columns and retrofits an existing table', async () => {
const schema = `task284_${Date.now()}`;
await observer.query(`CREATE SCHEMA "${schema}"`);
const client = await observer.connect();
try {
await client.query(`SET search_path TO "${schema}"`);
await client.query(TOOL_CALL_DDL);
const fresh = await client.query(
"SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = 'tool_calls'",
[schema],
);
expect(fresh.rows.map((row) => row.column_name)).toEqual(expect.arrayContaining([
'provider_repo', 'provider_ref',
]));

await client.query('DROP TABLE tool_calls');
await client.query(`CREATE TABLE tool_calls (
call_id VARCHAR(255) PRIMARY KEY, grant_id VARCHAR(255) NOT NULL,
pod_id VARCHAR(255), installation_id VARCHAR(255), agent_user_id VARCHAR(255) NOT NULL,
tool VARCHAR(255) NOT NULL, args_digest CHAR(64) NOT NULL,
occurred_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
outcome VARCHAR(32) NOT NULL, reason VARCHAR(255), approval_id VARCHAR(255),
duration_ms INTEGER, credential_owner_id VARCHAR(255)
)`);
await client.query(TOOL_CALL_DDL);
await client.query(`INSERT INTO tool_calls
(call_id, grant_id, agent_user_id, tool, args_digest, outcome, provider_repo, provider_ref)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
'retrofit-row', GRANT_ID, 'seat-a', 'github.push_branch', 'b'.repeat(64),
'ok', 'Team-Commonly/commonly', 'refs/heads/seat/seat-a/work',
]);
const { rows } = await client.query(
'SELECT provider_repo, provider_ref FROM tool_calls WHERE call_id = $1',
['retrofit-row'],
);
expect(rows[0]).toMatchObject({
provider_repo: 'Team-Commonly/commonly',
provider_ref: 'refs/heads/seat/seat-a/work',
});
} finally {
await client.query('RESET search_path');
client.release();
await observer.query(`DROP SCHEMA "${schema}" CASCADE`);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ describe('the trail schema retrofit', () => {
const statements = pool.query.mock.calls.map(([sql]) => String(sql));
expect(statements.some((sql) => /ALTER TABLE tool_calls ADD COLUMN IF NOT EXISTS credential_owner_id/.test(sql)))
.toBe(true);
expect(statements.some((sql) => /ALTER TABLE tool_calls ADD COLUMN IF NOT EXISTS provider_repo/.test(sql)))
.toBe(true);
expect(statements.some((sql) => /ALTER TABLE tool_calls ADD COLUMN IF NOT EXISTS provider_ref/.test(sql)))
.toBe(true);
const insert = statements.find((sql) => /INSERT INTO tool_calls/.test(sql));
expect(insert).toMatch(/credential_owner_id/);
// The value is bound, not interpolated: an owner id is data.
Expand Down
174 changes: 174 additions & 0 deletions backend/__tests__/unit/routes/mcpGrants.hostGh.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
const express = require('express');
const request = require('supertest');
const { readFileSync } = require('fs');
const { join } = require('path');

const mockCallTool = jest.fn();
const mockListToolsForGrant = jest.fn();
const mockReserveBudgetLineage = jest.fn();
const AGENT_ID = '507f1f77bcf86cd799439011';

jest.mock('../../../middleware/agentRuntimeAuth', () => (req, _res, next) => {
req.agentUser = { _id: AGENT_ID, username: 'kai', botMetadata: { agentName: 'kai', instanceId: 'default' } };
req.agentInstallation = { agentName: 'kai', instanceId: 'default' };
req.agentTokenHash = 'a'.repeat(64);
next();
});
jest.mock('../../../services/toolBrokerService', () => ({
listToolsForGrant: mockListToolsForGrant,
callTool: mockCallTool,
}));
jest.mock('../../../models/ToolCall', () => ({ reserveBudgetLineage: mockReserveBudgetLineage }));

// eslint-disable-next-line import/no-unresolved, import/extensions
const router = require('../../../routes/mcpGrants');

const app = express();
app.use('/api/mcp/grants/:grantId/push', express.json({ limit: '9mb' }));
app.use(express.json());
app.use('/api/mcp/grants', router);

const operations = [
['pr.create', 'github.create_pull_request', { title: 'Work', head: `seat/${AGENT_ID}/work`, base: 'main' }],
['pr.view', 'github.get_pull_request', { pullNumber: 12 }],
['pr.comment', 'github.comment_on_pull_request', { pullNumber: 12, body: 'A comment' }],
['pr.review_comment', 'github.review_pull_request_comment', { pullNumber: 12, body: 'A review' }],
['issue.comment', 'github.comment_on_issue', { issueNumber: 12, body: 'A comment' }],
];
const refusedOperations = ['pr.merge', 'pr.close', 'issue.close', 'api', 'branch.delete', 'pr.review_approve'];

beforeEach(() => {
jest.clearAllMocks();
mockReserveBudgetLineage.mockResolvedValue(true);
mockListToolsForGrant.mockResolvedValue(operations.map(([, name]) => ({ name })));
mockCallTool.mockResolvedValue({ callId: 'call-1', outcome: 'ok', result: { number: 12 } });
});

describe('host GitHub broker command boundary', () => {
it.each(operations)('maps %s to its fixed broker tool', async (operation, tool, args) => {
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({ operation, args });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ callId: 'call-1', outcome: 'ok' });
expect(mockCallTool).toHaveBeenCalledWith({
grantId: 'grant-1', agentUserId: AGENT_ID, agentTokenHash: 'a'.repeat(64),
agentName: 'kai', instanceId: 'default', tool, args,
});
expect(mockReserveBudgetLineage).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ calls: operation === 'pr.view' ? 30 : 10, windowMs: 60000 }),
]));
});

it.each(refusedOperations)('refuses %s before grant disclosure or provider call', async (operation) => {
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({ operation, args: {} });
expect(response.status).toBe(400);
expect(response.body.error).toBe('operation_not_allowed');
expect(mockListToolsForGrant).not.toHaveBeenCalled();
expect(mockCallTool).not.toHaveBeenCalled();
});

it('refuses a PR head outside the authenticated seat prefix', async () => {
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({
operation: 'pr.create', args: { title: 'Work', head: 'other/work', base: 'main' },
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('branch_not_owned_by_seat');
expect(mockCallTool).not.toHaveBeenCalled();
});

it.each([
`seat/${AGENT_ID}/../other`,
`seat/${AGENT_ID}//other`,
`seat/${AGENT_ID}/topic.lock`,
`seat/${AGENT_ID}/topic.`,
`seat/${AGENT_ID}/topic?x`,
])('refuses malformed seat branch %s before provider call', async (head) => {
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({
operation: 'pr.create', args: { title: 'Work', head, base: 'main' },
});
expect(response.status).toBe(403);
expect(mockCallTool).not.toHaveBeenCalled();
});

it('refuses an operation missing from this seat grant before budget use', async () => {
mockListToolsForGrant.mockResolvedValue([{ name: 'github.get_pull_request' }]);
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({
operation: 'issue.comment', args: { issueNumber: 12, body: 'Comment' },
});
expect(response.status).toBe(403);
expect(response.body.error).toBe('operation_not_granted');
expect(mockReserveBudgetLineage).not.toHaveBeenCalled();
expect(mockCallTool).not.toHaveBeenCalled();
});

it('refuses an exhausted token or grant budget', async () => {
mockReserveBudgetLineage.mockResolvedValue(false);
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({
operation: 'pr.view', args: { pullNumber: 12 },
});
expect(response.status).toBe(429);
expect(mockCallTool).not.toHaveBeenCalled();
});

it('returns approval state without executing another operation', async () => {
mockCallTool.mockRejectedValue(Object.assign(new Error('pending'), {
code: 'approval_required', details: { approvalId: 'approval-1' },
}));
const response = await request(app).post('/api/mcp/grants/grant-1/cli').send({
operation: 'pr.create', args: { title: 'Work', head: `seat/${AGENT_ID}/work`, base: 'main' },
});
expect(response.status).toBe(202);
expect(response.body).toEqual({ status: 'pending_approval', approvalId: 'approval-1' });
expect(mockCallTool).toHaveBeenCalledTimes(1);
});
});

describe('host GitHub push boundary', () => {
const push = {
ref: `refs/heads/seat/${AGENT_ID}/work`, baseOid: 'a'.repeat(40),
newOid: 'b'.repeat(40), bundle: 'YnVuZGxl',
};

it('requires the exact push tool and passes token identity to the shared broker budget', async () => {
mockListToolsForGrant.mockResolvedValue([{ name: 'github.push_branch' }]);
const response = await request(app).post('/api/mcp/grants/grant-1/push').send(push);
expect(response.status).toBe(200);
expect(mockCallTool).toHaveBeenCalledWith(expect.objectContaining({
grantId: 'grant-1', agentUserId: AGENT_ID, agentTokenHash: 'a'.repeat(64),
tool: 'github.push_branch', args: push,
}));
expect(mockReserveBudgetLineage).not.toHaveBeenCalled();
});

it('refuses a read-only or omitted push tool before budget use', async () => {
mockListToolsForGrant.mockResolvedValue([{ name: 'github.get_pull_request' }]);
const response = await request(app).post('/api/mcp/grants/grant-1/push').send(push);
expect(response.status).toBe(403);
expect(mockReserveBudgetLineage).not.toHaveBeenCalled();
expect(mockCallTool).not.toHaveBeenCalled();
});

it('returns the shared broker budget refusal', async () => {
mockListToolsForGrant.mockResolvedValue([{ name: 'github.push_branch' }]);
mockCallTool.mockRejectedValue(Object.assign(new Error('push budget exhausted'), {
code: 'host_github_push_rate_limited', statusCode: 429,
}));
const response = await request(app).post('/api/mcp/grants/grant-1/push').send(push);
expect(response.status).toBe(429);
expect(mockCallTool).toHaveBeenCalledTimes(1);
});

it('preserves the larger push parser before the global JSON parser in the real server', async () => {
expect(typeof router.pushBodyRateLimit).toBe('function');
const source = readFileSync(join(__dirname, '../../../server.ts'), 'utf8');
expect(source.indexOf("app.use('/api/mcp/grants/:grantId/push', mcpGrantsRoutes.pushBodyRateLimit,"))
.toBeGreaterThan(-1);
expect(source.indexOf("app.use('/api/mcp/grants/:grantId/push'"))
.toBeLessThan(source.indexOf('app.use(express.json());'));
mockListToolsForGrant.mockResolvedValue([{ name: 'github.push_branch' }]);
const response = await request(app).post('/api/mcp/grants/grant-1/push').send({
...push, bundle: 'A'.repeat(200_000),
});
expect(response.status).toBe(200);
expect(mockCallTool).toHaveBeenCalledTimes(1);
});
});
Loading
Loading