@orboto/mcp 0.181.0 → 0.182.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/orboto-client.js +2 -2
- package/dist/required-rules-wrapper.test.js +113 -0
- package/dist/required-rules.js +92 -0
- package/dist/required-rules.test.js +110 -0
- package/dist/resources.js +3 -2
- package/dist/resources.test.js +12 -2
- package/dist/server.js +10 -9
- package/dist/session-nudge.js +9 -6
- package/dist/session-nudge.test.js +8 -3
- package/dist/session-nudge.transport.test.js +21 -3
- package/dist/tool-annotations.test.js +22 -0
- package/dist/tool-docs.test.js +1 -0
- package/dist/tools/git-health-reasons.js +10 -0
- package/dist/tools/session-start.js +9 -19
- package/dist/tools/session-start.test.js +8 -8
- package/dist/tools/ticket-writes.js +6 -5
- package/dist/tools/ticket-writes.test.js +6 -0
- package/dist/tools/work-next.js +208 -0
- package/dist/tools/work-sessions-next.test.js +33 -7
- package/dist/tools/work-sessions-shared.js +17 -0
- package/dist/tools/work-sessions-start.test.js +3 -0
- package/dist/tools/work-sessions.js +10 -192
- package/dist/with-metrics.js +11 -3
- package/package.json +1 -1
package/dist/orboto-client.js
CHANGED
|
@@ -107,8 +107,8 @@ export class OrbotoClient {
|
|
|
107
107
|
return res;
|
|
108
108
|
}
|
|
109
109
|
/** GET a JSON endpoint. Throws `OrbotoApiError` on non-2xx. */
|
|
110
|
-
async get(path) {
|
|
111
|
-
const res = await this.authedFetch(this.fullUrl(path), { method: 'GET' });
|
|
110
|
+
async get(path, options = {}) {
|
|
111
|
+
const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal });
|
|
112
112
|
return (await res.json());
|
|
113
113
|
}
|
|
114
114
|
/** POST a JSON body. */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
9
|
+
const cleanup = [];
|
|
10
|
+
afterEach(async () => { for (const fn of cleanup.splice(0).reverse())
|
|
11
|
+
await fn(); });
|
|
12
|
+
async function fixture() {
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'orboto-rules-wrapper-'));
|
|
14
|
+
cleanup.push(() => rm(dir, { recursive: true, force: true }));
|
|
15
|
+
await mkdir(join(dir, 'skills/orboto'), { recursive: true });
|
|
16
|
+
await cp(join(root, 'skills/orboto/scripts'), join(dir, 'skills/orboto/scripts'), { recursive: true });
|
|
17
|
+
const cache = join(dir, 'skills/orboto/.rules-hash');
|
|
18
|
+
await writeFile(cache, 'keep-hash\n');
|
|
19
|
+
let rules = '{}', next = '{}', status = 200, ruleReads = 0;
|
|
20
|
+
const server = createServer((req, res) => {
|
|
21
|
+
res.setHeader('Content-Type', 'application/json');
|
|
22
|
+
if (req.url?.startsWith('/agent-instructions')) {
|
|
23
|
+
ruleReads++;
|
|
24
|
+
res.statusCode = status;
|
|
25
|
+
res.end(rules);
|
|
26
|
+
}
|
|
27
|
+
else if (req.url?.startsWith('/work-sessions/next'))
|
|
28
|
+
res.end(next);
|
|
29
|
+
else if (req.url?.startsWith('/users/me/assigned-tickets'))
|
|
30
|
+
res.end('{"items":[]}');
|
|
31
|
+
else if (req.url?.startsWith('/users/me'))
|
|
32
|
+
res.end('{"id":"me","email":"fixture@example.test"}');
|
|
33
|
+
else
|
|
34
|
+
res.end('null');
|
|
35
|
+
});
|
|
36
|
+
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
|
|
37
|
+
cleanup.push(() => new Promise((ok, no) => server.close((err) => err ? no(err) : ok())));
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === 'string')
|
|
40
|
+
throw new Error('fixture address missing');
|
|
41
|
+
const origin = `http://127.0.0.1:${address.port}`;
|
|
42
|
+
const guard = join(dir, 'fixture-network.mjs');
|
|
43
|
+
await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused a nonlocal request'); return original(input, init); };`);
|
|
44
|
+
const run = (args = ['session-start', '--force-rules']) => new Promise((ok, no) => {
|
|
45
|
+
const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
|
|
46
|
+
cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: `http://127.0.0.1:${address.port}`, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
});
|
|
49
|
+
let out = '', err = '';
|
|
50
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString(); });
|
|
51
|
+
child.stderr.on('data', (chunk) => { err += chunk.toString(); });
|
|
52
|
+
child.on('error', no);
|
|
53
|
+
child.on('close', (code) => ok({ code, out, err }));
|
|
54
|
+
});
|
|
55
|
+
return { run, cache, ruleReads: () => ruleReads, set: (body, code = 200) => { rules = body; status = code; }, setNext: (body) => { next = body; } };
|
|
56
|
+
}
|
|
57
|
+
describe('wrapper required rules through the real CLI', () => {
|
|
58
|
+
it('does not turn malformed work-next responses into an idle result', async () => {
|
|
59
|
+
const f = await fixture();
|
|
60
|
+
for (const body of ['{}', 'null', '[]', '{"reserved":null}', '{"reserved":""}', '<html>unavailable</html>']) {
|
|
61
|
+
f.setNext(body);
|
|
62
|
+
const result = await f.run(['work-next', 'ORB']);
|
|
63
|
+
expect(result.code).toBe(1);
|
|
64
|
+
expect(result.out).not.toContain('Nothing ready');
|
|
65
|
+
expect(await readFile(f.cache, 'utf8')).toBe('keep-hash\n');
|
|
66
|
+
}
|
|
67
|
+
f.setNext('{"reserved":null,"reason":"none-matching","retryAfterSeconds":null,"earliestFreeAt":null,"candidatesConsidered":0,"landedIdle":[]}');
|
|
68
|
+
expect((await f.run(['work-next', 'ORB'])).code).toBe(3);
|
|
69
|
+
f.setNext(JSON.stringify({ reserved: {
|
|
70
|
+
session: { id: 'session', role: 'implementation', leaseUntil: 'later' },
|
|
71
|
+
ticket: { ticketKey: 'ORB-42', title: 'fixture' }, reused: true,
|
|
72
|
+
rulesHash: 'keep-hash', rulesUnchanged: true,
|
|
73
|
+
primer: { markdown: '', totalTokens: 0 }, checklists: [], dependencies: { blocks: [], blockedBy: [] }, gitHealth: [], siblingSessions: [],
|
|
74
|
+
}, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] }));
|
|
75
|
+
const ack = await f.run(['work-next', 'ORB']);
|
|
76
|
+
expect(ack.code).toBe(0);
|
|
77
|
+
expect(ack.out).toContain('not currently in your context');
|
|
78
|
+
expect(ack.out).toContain('session-start --force-rules');
|
|
79
|
+
expect(ack.out).not.toContain('keep following what you already loaded');
|
|
80
|
+
});
|
|
81
|
+
it('does not silently omit an explicitly requested init rules snapshot', async () => {
|
|
82
|
+
const f = await fixture();
|
|
83
|
+
f.set('{}');
|
|
84
|
+
const result = await f.run(['init', '--with-rules']);
|
|
85
|
+
expect(result.code).toBe(1);
|
|
86
|
+
expect(result.err).toContain('Required agent rules');
|
|
87
|
+
expect(f.ruleReads()).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
it('fails closed without poisoning the cache, then recovers with a valid empty ruleset', async () => {
|
|
90
|
+
const f = await fixture();
|
|
91
|
+
for (const body of ['{}', 'null', '[]', '{"instructions":', '<html>secret-token</html>', '{"instructions":"","rulesHash":null}', '{"rulesHash":"keep-hash","rulesUnchanged":true}']) {
|
|
92
|
+
f.set(body);
|
|
93
|
+
const result = await f.run();
|
|
94
|
+
expect(result.code).toBe(1);
|
|
95
|
+
expect(result.out).not.toContain('no workspace rules');
|
|
96
|
+
expect(result.err).toContain('Required agent rules');
|
|
97
|
+
expect(result.err).not.toContain('secret-token');
|
|
98
|
+
expect(await readFile(f.cache, 'utf8')).toBe('keep-hash\n');
|
|
99
|
+
}
|
|
100
|
+
for (const status of [401, 403, 500, 503]) {
|
|
101
|
+
f.set('{"error":"secret-token"}', status);
|
|
102
|
+
const result = await f.run();
|
|
103
|
+
expect(result.code).toBe(1);
|
|
104
|
+
expect(result.err).toContain(`HTTP ${status}`);
|
|
105
|
+
expect(result.err).not.toContain('secret-token');
|
|
106
|
+
}
|
|
107
|
+
f.set('{"instructions":"","rulesHash":"empty","requireSessionStart":true}');
|
|
108
|
+
const result = await f.run();
|
|
109
|
+
expect(result.code).toBe(0);
|
|
110
|
+
expect(result.out).toContain('no workspace rules configured');
|
|
111
|
+
expect((await readFile(f.cache, 'utf8')).trim()).toBe('empty');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { OrbotoApiError } from './orboto-client.js';
|
|
2
|
+
/** Fixed diagnostics only: neither rule text nor upstream bodies belong in logs. */
|
|
3
|
+
export class RequiredRulesError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
status;
|
|
6
|
+
errorKey = 'errors.agent_rules.unavailable';
|
|
7
|
+
constructor(reason, status) {
|
|
8
|
+
super(`Required agent rules could not be loaded (${reason}${status ? ` ${status}` : ''}). Do not proceed without the rules. Check connectivity and authentication, then retry session-start with forceRules.`);
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.name = 'RequiredRulesError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function record(value) {
|
|
15
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
/** Validate receipts before caching their hash or claiming delivery succeeded. */
|
|
18
|
+
export function validateRulesReceipt(value, knownHash, textField = 'instructions') {
|
|
19
|
+
if (!record(value) || typeof value.rulesHash !== 'string' || !value.rulesHash
|
|
20
|
+
|| ('rulesUnchanged' in value && typeof value.rulesUnchanged !== 'boolean')) {
|
|
21
|
+
throw new RequiredRulesError('invalid_response');
|
|
22
|
+
}
|
|
23
|
+
if (value.rulesUnchanged === true) {
|
|
24
|
+
if (!knownHash || value.rulesHash !== knownHash)
|
|
25
|
+
throw new RequiredRulesError('unbound_ack');
|
|
26
|
+
}
|
|
27
|
+
else if (typeof value[textField] !== 'string') {
|
|
28
|
+
throw new RequiredRulesError('invalid_response');
|
|
29
|
+
}
|
|
30
|
+
if (('requireSessionStart' in value && typeof value.requireSessionStart !== 'boolean')
|
|
31
|
+
|| ('rulesChars' in value && (!Number.isInteger(value.rulesChars) || Number(value.rulesChars) < 0))
|
|
32
|
+
|| ('rulesIndex' in value && (!Array.isArray(value.rulesIndex) || value.rulesIndex.some((row) => !record(row)
|
|
33
|
+
|| typeof row.title !== 'string' || !Number.isInteger(row.chars) || Number(row.chars) < 0)))) {
|
|
34
|
+
throw new RequiredRulesError('invalid_response');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function validateNextWorkEnvelope(value, peek = false) {
|
|
38
|
+
const integer = (n) => Number.isInteger(n) && Number(n) >= 0;
|
|
39
|
+
const candidate = record(value) ? value.candidate : undefined;
|
|
40
|
+
const hasCandidate = candidate !== undefined && candidate !== null;
|
|
41
|
+
if (!record(value) || !('reserved' in value) || (value.reserved !== null && !record(value.reserved))
|
|
42
|
+
|| (peek && value.reserved !== null)
|
|
43
|
+
|| (hasCandidate && (!peek || value.reserved !== null || value.reason !== null
|
|
44
|
+
|| !record(candidate) || typeof candidate.ticketId !== 'string' || !candidate.ticketId
|
|
45
|
+
|| (candidate.ticketKey !== null && typeof candidate.ticketKey !== 'string')
|
|
46
|
+
|| typeof candidate.title !== 'string' || (candidate.priority !== null && typeof candidate.priority !== 'string')))
|
|
47
|
+
|| (value.reserved === null
|
|
48
|
+
? !hasCandidate && (typeof value.reason !== 'string' || !['all-blocked', 'all-leased', 'none-matching', 'autonomy_paused', 'lane_paused', 'lane_limit_reached'].includes(value.reason))
|
|
49
|
+
: value.reason !== null)
|
|
50
|
+
|| !integer(value.candidatesConsidered)
|
|
51
|
+
|| (value.retryAfterSeconds !== null && !integer(value.retryAfterSeconds))
|
|
52
|
+
|| (value.earliestFreeAt !== null && typeof value.earliestFreeAt !== 'string')
|
|
53
|
+
|| !Array.isArray(value.landedIdle)
|
|
54
|
+
|| value.landedIdle.some((row) => !record(row) || typeof row.ticketId !== 'string' || !row.ticketId
|
|
55
|
+
|| (row.ticketKey !== null && typeof row.ticketKey !== 'string') || typeof row.title !== 'string'
|
|
56
|
+
|| typeof row.lastActivityAt !== 'string' || !integer(row.idleWorkingDays) || !integer(row.commitCount))) {
|
|
57
|
+
throw new RequiredRulesError('invalid_response');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** One bounded, required read shared by session-start, resources and connect. */
|
|
61
|
+
export async function loadRequiredRules(client, path, knownHash) {
|
|
62
|
+
let value;
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
let deadline;
|
|
65
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
66
|
+
deadline = setTimeout(() => {
|
|
67
|
+
controller.abort();
|
|
68
|
+
reject(new RequiredRulesError('timeout'));
|
|
69
|
+
}, 10_000);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
// Race the whole operation: OAuth token/keychain acquisition can stall
|
|
73
|
+
// before fetch starts and therefore cannot observe fetch's abort signal.
|
|
74
|
+
value = await Promise.race([client.get(path, { signal: controller.signal }), timeout]);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error instanceof RequiredRulesError)
|
|
78
|
+
throw error;
|
|
79
|
+
if (error instanceof OrbotoApiError)
|
|
80
|
+
throw new RequiredRulesError('http', error.status);
|
|
81
|
+
if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'))
|
|
82
|
+
throw new RequiredRulesError('timeout');
|
|
83
|
+
if (error instanceof SyntaxError)
|
|
84
|
+
throw new RequiredRulesError('invalid_response');
|
|
85
|
+
throw new RequiredRulesError('transport');
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(deadline);
|
|
89
|
+
}
|
|
90
|
+
validateRulesReceipt(value, knownHash);
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { OrbotoApiError, OrbotoClient } from './orboto-client.js';
|
|
3
|
+
import { loadRequiredRules, RequiredRulesError, validateRulesReceipt } from './required-rules.js';
|
|
4
|
+
import { makeSessionStartHandler } from './tools/session-start.js';
|
|
5
|
+
import { makeWorkNextHandler } from './tools/work-sessions.js';
|
|
6
|
+
import { createNudgeState, SESSION_START_TOOL } from './session-nudge.js';
|
|
7
|
+
import { withMetrics } from './with-metrics.js';
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
const makeClient = () => new OrbotoClient({ baseUrl: 'https://fixture.invalid', apiKey: 'orb_fixture' });
|
|
10
|
+
const full = { instructions: ' exact rules\n', rulesHash: 'hash', requireSessionStart: true };
|
|
11
|
+
describe('required rule delivery', () => {
|
|
12
|
+
it.each([null, {}, [], { reserved: null }, { reserved: '' }, { reserved: null, reason: ['none-matching'] }])('does not turn an incomplete dispatch response into idle %#', async (body) => {
|
|
13
|
+
const client = makeClient();
|
|
14
|
+
vi.spyOn(client, 'post').mockResolvedValue(body);
|
|
15
|
+
await expect(makeWorkNextHandler(client)({ projectKey: 'ORB' })).rejects.toBeInstanceOf(RequiredRulesError);
|
|
16
|
+
});
|
|
17
|
+
it.each([null, [], {}, 'HTML secret', { rulesHash: 'hash' }, { instructions: '' },
|
|
18
|
+
{ instructions: null, rulesHash: 'hash' }, { ...full, rulesUnchanged: null },
|
|
19
|
+
{ ...full, rulesIndex: [{}] }, { ...full, rulesChars: -1 }, { ...full, requireSessionStart: 'false' },
|
|
20
|
+
])('rejects incomplete successful response %# without leaking it', async (body) => {
|
|
21
|
+
const client = makeClient();
|
|
22
|
+
vi.spyOn(client, 'get').mockResolvedValue(body);
|
|
23
|
+
await expect(loadRequiredRules(client, '/agent-instructions')).rejects.toMatchObject({ reason: 'invalid_response' });
|
|
24
|
+
});
|
|
25
|
+
it.each([new Error('orb_secret network detail'), new DOMException('secret', 'TimeoutError'),
|
|
26
|
+
new SyntaxError('secret invalid JSON'), ...[401, 403, 500, 503].map((s) => new OrbotoApiError(s, 'secret body', 'https://secret.invalid')),
|
|
27
|
+
])('reports transport/auth/server failures safely %#', async (failure) => {
|
|
28
|
+
const client = makeClient();
|
|
29
|
+
vi.spyOn(client, 'get').mockRejectedValue(failure);
|
|
30
|
+
const error = await loadRequiredRules(client, '/agent-instructions').catch((e) => e);
|
|
31
|
+
expect(error).toBeInstanceOf(RequiredRulesError);
|
|
32
|
+
expect(String(error)).not.toContain('secret');
|
|
33
|
+
expect(String(error).length).toBeLessThan(512);
|
|
34
|
+
});
|
|
35
|
+
it('preserves exact full text, valid empty rules and bound acknowledgements', async () => {
|
|
36
|
+
const client = makeClient();
|
|
37
|
+
const get = vi.spyOn(client, 'get').mockResolvedValueOnce(full).mockResolvedValueOnce({ ...full, instructions: '' });
|
|
38
|
+
expect(await loadRequiredRules(client, '/agent-instructions')).toEqual(full);
|
|
39
|
+
expect((await loadRequiredRules(client, '/agent-instructions')).instructions).toBe('');
|
|
40
|
+
expect(get.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
|
|
41
|
+
expect(() => validateRulesReceipt({ rulesHash: 'hash', rulesUnchanged: true }, 'hash')).not.toThrow();
|
|
42
|
+
expect(() => validateRulesReceipt({ rulesHash: 'hash', rulesUnchanged: true })).toThrow(RequiredRulesError);
|
|
43
|
+
expect(() => validateRulesReceipt({ rulesHash: 'other', rulesUnchanged: true }, 'hash')).toThrow(RequiredRulesError);
|
|
44
|
+
});
|
|
45
|
+
it.each([false, true])('failed session-start rulesOnly=%s stays gated and recovery succeeds without a poisoned ack', async (rulesOnly) => {
|
|
46
|
+
const client = makeClient();
|
|
47
|
+
let broken = true;
|
|
48
|
+
const paths = [];
|
|
49
|
+
vi.spyOn(client, 'post').mockResolvedValue({});
|
|
50
|
+
vi.spyOn(client, 'get').mockImplementation(async (path) => {
|
|
51
|
+
if (path.startsWith('/agent-instructions')) {
|
|
52
|
+
paths.push(path);
|
|
53
|
+
if (broken)
|
|
54
|
+
throw new Error('secret transport');
|
|
55
|
+
return full;
|
|
56
|
+
}
|
|
57
|
+
if (path.includes('assigned-tickets'))
|
|
58
|
+
return { items: [] };
|
|
59
|
+
if (path.includes('/messages'))
|
|
60
|
+
return { messages: [] };
|
|
61
|
+
return null;
|
|
62
|
+
});
|
|
63
|
+
const state = createNudgeState(true);
|
|
64
|
+
const session = withMetrics(client, SESSION_START_TOOL, undefined, makeSessionStartHandler(client), state);
|
|
65
|
+
const write = vi.fn(async () => ({ content: [] }));
|
|
66
|
+
const other = withMetrics(client, 'orboto_create_ticket', undefined, write, state);
|
|
67
|
+
const failed = await session({ rulesOnly, forceRules: true });
|
|
68
|
+
expect(failed.isError).toBe(true);
|
|
69
|
+
expect(failed.structuredContent).toMatchObject({ errorKey: 'errors.agent_rules.unavailable' });
|
|
70
|
+
expect(state.sessionStartRan).toBe(false);
|
|
71
|
+
expect((await other({})).isError).toBe(true);
|
|
72
|
+
expect(write).not.toHaveBeenCalled();
|
|
73
|
+
broken = false;
|
|
74
|
+
expect((await session({ rulesOnly, forceRules: true })).isError).not.toBe(true);
|
|
75
|
+
expect(state.sessionStartRan).toBe(true);
|
|
76
|
+
await other({});
|
|
77
|
+
expect(write).toHaveBeenCalledOnce();
|
|
78
|
+
expect(paths.every((path) => !path.includes('knownRulesHash'))).toBe(true);
|
|
79
|
+
broken = true;
|
|
80
|
+
expect((await session({ rulesOnly, forceRules: true })).isError).toBe(true);
|
|
81
|
+
expect(state.sessionStartRan).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
it.each(['token', 'refresh', 'fetch'])('bounds the complete operation including a stalled %s', async (stage) => {
|
|
84
|
+
vi.useFakeTimers();
|
|
85
|
+
try {
|
|
86
|
+
const never = () => new Promise(() => { });
|
|
87
|
+
const client = new OrbotoClient({ baseUrl: 'https://fixture.invalid', tokenProvider: {
|
|
88
|
+
getAccessToken: stage === 'token' ? never : async () => 'fixture',
|
|
89
|
+
forceRefresh: never,
|
|
90
|
+
} });
|
|
91
|
+
let signal;
|
|
92
|
+
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
|
93
|
+
signal = init?.signal;
|
|
94
|
+
if (stage === 'refresh')
|
|
95
|
+
return new Response('', { status: 401 });
|
|
96
|
+
return new Promise(() => { });
|
|
97
|
+
});
|
|
98
|
+
const rejected = expect(loadRequiredRules(client, '/agent-instructions')).rejects.toMatchObject({ reason: 'timeout' });
|
|
99
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
100
|
+
await rejected;
|
|
101
|
+
if (stage === 'token')
|
|
102
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
103
|
+
else
|
|
104
|
+
expect(signal?.aborted).toBe(true);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
vi.useRealTimers();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
package/dist/resources.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
28
28
|
import { OrbotoApiError } from './orboto-client.js';
|
|
29
|
+
import { loadRequiredRules } from './required-rules.js';
|
|
29
30
|
import { resolveProjectByKey, resolveTicketByKey } from './tools/shared.js';
|
|
30
31
|
export function registerOrbotoResources(server, client) {
|
|
31
32
|
// -------------------------------------------------------------------------
|
|
@@ -41,8 +42,8 @@ export function registerOrbotoResources(server, client) {
|
|
|
41
42
|
description: 'The complete, assembled binding rules you must follow as an agent in this workspace - cap-independent (the MCP instructions block may be truncated; this resource is not). orboto_session_start returns the same rules plus your in-progress work.',
|
|
42
43
|
mimeType: 'text/markdown',
|
|
43
44
|
}, async (uri) => {
|
|
44
|
-
const res = await client
|
|
45
|
-
const rules = res
|
|
45
|
+
const res = await loadRequiredRules(client, '/agent-instructions');
|
|
46
|
+
const rules = res.instructions || '(no workspace rules configured)';
|
|
46
47
|
return {
|
|
47
48
|
contents: [{ uri: uri.href, mimeType: 'text/markdown', text: `# orboto workspace agent rules\n\n${rules}` }],
|
|
48
49
|
};
|
package/dist/resources.test.js
CHANGED
|
@@ -171,8 +171,18 @@ describe('orboto:// search resource', () => {
|
|
|
171
171
|
});
|
|
172
172
|
});
|
|
173
173
|
describe('orboto:// rules resource (ORB-1177)', () => {
|
|
174
|
+
it.each([null, {}, [], { instructions: '' }])('refuses unavailable or malformed rules %#', async (body) => {
|
|
175
|
+
stub([{ json: body }]);
|
|
176
|
+
const handler = getTemplateHandler(buildServerWithResources(), 'rules');
|
|
177
|
+
await expect(handler(new URL('orboto://rules'), {})).rejects.toThrow('Required agent rules');
|
|
178
|
+
});
|
|
179
|
+
it('refuses a failed fetch instead of returning an empty placeholder', async () => {
|
|
180
|
+
stub([{ ok: false, status: 503 }]);
|
|
181
|
+
const handler = getTemplateHandler(buildServerWithResources(), 'rules');
|
|
182
|
+
await expect(handler(new URL('orboto://rules'), {})).rejects.toThrow('Required agent rules');
|
|
183
|
+
});
|
|
174
184
|
it('returns the complete assembled workspace rules', async () => {
|
|
175
|
-
stub([{ json: { instructions: 'RULE 1: ticket-first.\nRULE 2: one commit per ticket.' } }]);
|
|
185
|
+
stub([{ json: { instructions: 'RULE 1: ticket-first.\nRULE 2: one commit per ticket.', rulesHash: 'fixture' } }]);
|
|
176
186
|
const server = buildServerWithResources();
|
|
177
187
|
const handler = getTemplateHandler(server, 'rules');
|
|
178
188
|
const out = await handler(new URL('orboto://rules'), {});
|
|
@@ -182,7 +192,7 @@ describe('orboto:// rules resource (ORB-1177)', () => {
|
|
|
182
192
|
expect(text).toContain('RULE 2: one commit per ticket.');
|
|
183
193
|
});
|
|
184
194
|
it('degrades to a placeholder when no rules are configured', async () => {
|
|
185
|
-
stub([{ json: { instructions: '' } }]);
|
|
195
|
+
stub([{ json: { instructions: '', rulesHash: 'empty' } }]);
|
|
186
196
|
const server = buildServerWithResources();
|
|
187
197
|
const handler = getTemplateHandler(server, 'rules');
|
|
188
198
|
const out = await handler(new URL('orboto://rules'), {});
|
package/dist/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { aiStatusToolConfig, makeAiStatusHandler } from './tools/ai-status.js';
|
|
|
26
26
|
import { embeddingStatusToolConfig, makeEmbeddingStatusHandler } from './tools/embedding-status.js';
|
|
27
27
|
import { aiUsageToolConfig, makeAiUsageHandler } from './tools/ai-usage.js';
|
|
28
28
|
import { sessionStartToolConfig, makeSessionStartHandler } from './tools/session-start.js';
|
|
29
|
+
import { loadRequiredRules } from './required-rules.js';
|
|
29
30
|
import { responseExpandToolConfig, makeResponseExpandHandler } from './tools/response-expand.js';
|
|
30
31
|
import { helpToolConfig, makeHelpHandler } from './tools/help.js';
|
|
31
32
|
import { apiSearchToolConfig, makeApiSearchHandler } from './tools/api-search.js';
|
|
@@ -87,7 +88,8 @@ import { setParentToolConfig, makeSetParentHandler } from './tools/set-parent.js
|
|
|
87
88
|
import { applyAgentProfile } from './tools/shared.js';
|
|
88
89
|
import { updateProjectToolConfig, makeUpdateProjectHandler, createProjectToolConfig, makeCreateProjectHandler, archiveProjectToolConfig, makeArchiveProjectHandler, } from './tools/update-project.js';
|
|
89
90
|
import { checkSimilarToolConfig, makeCheckSimilarHandler } from './tools/check-similar.js';
|
|
90
|
-
import { workSessionStartToolConfig, makeWorkSessionStartHandler, workSessionFinishToolConfig, makeWorkSessionFinishHandler, workSessionsToolConfig, makeWorkSessionsHandler, workSessionClaimsAddToolConfig, makeWorkSessionClaimsAddHandler, workSessionClaimsReleaseToolConfig, makeWorkSessionClaimsReleaseHandler, workStartToolConfig, makeWorkStartHandler, workFinishToolConfig, makeWorkFinishHandler,
|
|
91
|
+
import { workSessionStartToolConfig, makeWorkSessionStartHandler, workSessionFinishToolConfig, makeWorkSessionFinishHandler, workSessionsToolConfig, makeWorkSessionsHandler, workSessionClaimsAddToolConfig, makeWorkSessionClaimsAddHandler, workSessionClaimsReleaseToolConfig, makeWorkSessionClaimsReleaseHandler, workStartToolConfig, makeWorkStartHandler, workFinishToolConfig, makeWorkFinishHandler, } from './tools/work-sessions.js';
|
|
92
|
+
import { workNextToolConfig, makeWorkNextHandler } from './tools/work-next.js';
|
|
91
93
|
import { listAdminTranslationsToolConfig, makeListAdminTranslationsHandler, approveTranslationToolConfig, makeApproveTranslationHandler, revertTranslationToolConfig, makeAdminRevertTranslationHandler, } from './tools/admin-translations.js';
|
|
92
94
|
import { listAgentDriftToolConfig, makeListAgentDriftHandler, resolveAgentDriftToolConfig, makeResolveAgentDriftHandler, } from './tools/agent-drift.js';
|
|
93
95
|
/** ORB-1090 - the workspace's configurable working-rules, used as the
|
|
@@ -170,14 +172,13 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
170
172
|
// ORB-1090 - fetch the workspace's configurable working-rules at
|
|
171
173
|
// connect so admin edits propagate to every new MCP session. The
|
|
172
174
|
// instructions block is the one place every MCP client reliably sees
|
|
173
|
-
// the rules (clients don't read the repo's CLAUDE.md).
|
|
174
|
-
//
|
|
175
|
-
// or the fetch fails.
|
|
175
|
+
// the rules (clients don't read the repo's CLAUDE.md). If unavailable,
|
|
176
|
+
// keep the connection usable for recovery but gate all other tools.
|
|
176
177
|
let workingRules = FALLBACK_WORKING_RULES;
|
|
177
178
|
// ORB-1471 - the same connect-time fetch also reports whether this
|
|
178
179
|
// workspace requires `orboto_session_start` before any other tool call
|
|
179
|
-
// (the hard gate).
|
|
180
|
-
let requireSessionStart =
|
|
180
|
+
// (the hard gate). Unknown policy must never silently disable the gate.
|
|
181
|
+
let requireSessionStart = true;
|
|
181
182
|
try {
|
|
182
183
|
// ORB-1753 - stdio deployments can declare their profile via
|
|
183
184
|
// ORBOTO_AGENT_KIND / ORBOTO_MODEL_TIER; api-key standing profiles
|
|
@@ -185,13 +186,13 @@ export async function buildOrbotoMcpServer(opts) {
|
|
|
185
186
|
const connectParams = new URLSearchParams();
|
|
186
187
|
applyAgentProfile(connectParams);
|
|
187
188
|
const connectQs = connectParams.toString();
|
|
188
|
-
const res = await client
|
|
189
|
+
const res = await loadRequiredRules(client, `/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
|
|
189
190
|
if (res?.instructions?.trim())
|
|
190
191
|
workingRules = res.instructions.trim();
|
|
191
|
-
requireSessionStart = res
|
|
192
|
+
requireSessionStart = res.requireSessionStart ?? true;
|
|
192
193
|
}
|
|
193
194
|
catch {
|
|
194
|
-
|
|
195
|
+
workingRules = 'Workspace rules are unavailable. Built-in hints are not a substitute. Call orboto_session_start with forceRules and retry after connectivity or authentication recovers.';
|
|
195
196
|
}
|
|
196
197
|
const server = new McpServer({ name: 'orboto', version: VERSION }, {
|
|
197
198
|
// ORB-940 - advertise resources/subscribe so MCP-aware clients
|
package/dist/session-nudge.js
CHANGED
|
@@ -49,29 +49,32 @@ export const SESSION_START_GATE_MESSAGE = 'This workspace requires you to load i
|
|
|
49
49
|
'other action. Call `orboto_session_start` now - it returns the rules you ' +
|
|
50
50
|
'must follow plus your in-progress work - then retry this call.';
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* Advance the gate state for one dispatch and report whether this
|
|
53
53
|
* dispatch must be REFUSED (returned an instructive error without running the
|
|
54
54
|
* handler).
|
|
55
55
|
*
|
|
56
56
|
* Semantics when the gate is enabled: every tool call other than
|
|
57
57
|
* `orboto_session_start` is refused until `orboto_session_start` has run once
|
|
58
|
-
* this session.
|
|
58
|
+
* this session. A rule refresh locks the session until its result succeeds
|
|
59
59
|
* (and is itself never gated). When the gate is disabled, nothing is ever
|
|
60
60
|
* refused (returns false) - default behaviour is unchanged.
|
|
61
61
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* The metrics wrapper records successful completion separately; dispatch
|
|
63
|
+
* alone is not evidence of rule delivery.
|
|
64
64
|
*/
|
|
65
65
|
export function shouldGate(state, toolName) {
|
|
66
66
|
if (toolName === SESSION_START_TOOL) {
|
|
67
|
-
|
|
68
|
-
state.sessionStartRan = true;
|
|
67
|
+
state.sessionStartRan = false;
|
|
69
68
|
return false;
|
|
70
69
|
}
|
|
71
70
|
if (!state.gateEnabled)
|
|
72
71
|
return false;
|
|
73
72
|
return !state.sessionStartRan;
|
|
74
73
|
}
|
|
74
|
+
export function recordSessionStartResult(state, toolName, success) {
|
|
75
|
+
if (toolName === SESSION_START_TOOL)
|
|
76
|
+
state.sessionStartRan = success;
|
|
77
|
+
}
|
|
75
78
|
/** ORB-1471 - the instructive refusal result the gate returns. */
|
|
76
79
|
export function gateResult() {
|
|
77
80
|
return {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* call is orboto_session_start, and never mutate structuredContent.
|
|
7
7
|
*/
|
|
8
8
|
import { describe, it, expect } from 'vitest';
|
|
9
|
-
import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, gateResult, prependNudge, } from './session-nudge.js';
|
|
9
|
+
import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, recordSessionStartResult, gateResult, prependNudge, } from './session-nudge.js';
|
|
10
10
|
describe('shouldNudge (ORB-1331)', () => {
|
|
11
11
|
it('nudges on the first tool call when it is not session_start, then never again', () => {
|
|
12
12
|
const state = createNudgeState();
|
|
@@ -44,15 +44,20 @@ describe('shouldGate (ORB-1471)', () => {
|
|
|
44
44
|
// Pre session_start: every other tool is gated.
|
|
45
45
|
expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
|
|
46
46
|
expect(shouldGate(state, 'orboto_create_ticket')).toBe(true);
|
|
47
|
-
//
|
|
47
|
+
// Dispatch is not proof of delivery; only successful completion unlocks.
|
|
48
48
|
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
49
|
+
expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
|
|
50
|
+
recordSessionStartResult(state, SESSION_START_TOOL, true);
|
|
49
51
|
// Post session_start: everything passes.
|
|
50
52
|
expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
|
|
51
53
|
expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
|
|
52
54
|
});
|
|
53
|
-
it('
|
|
55
|
+
it('a failed refresh keeps the gate closed and a successful retry unlocks', () => {
|
|
54
56
|
const state = createNudgeState(true);
|
|
55
57
|
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
58
|
+
recordSessionStartResult(state, SESSION_START_TOOL, false);
|
|
59
|
+
expect(shouldGate(state, 'orboto_get_ticket')).toBe(true);
|
|
60
|
+
recordSessionStartResult(state, SESSION_START_TOOL, true);
|
|
56
61
|
expect(shouldGate(state, 'orboto_get_ticket')).toBe(false);
|
|
57
62
|
});
|
|
58
63
|
it('gateResult is an instructive isError result pointing at session_start', () => {
|
|
@@ -24,14 +24,14 @@ import { SESSION_START_NUDGE } from './session-nudge.js';
|
|
|
24
24
|
// Every API call the tools make resolves to a benign empty payload so
|
|
25
25
|
// the handlers succeed without a live backend. list_projects → [] →
|
|
26
26
|
// "no projects" text + valid structuredContent; session_start's sub-
|
|
27
|
-
//
|
|
27
|
+
// rules read is required and carries an explicit valid empty ruleset.
|
|
28
28
|
function mockApi() {
|
|
29
|
-
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
29
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => ({
|
|
30
30
|
ok: true,
|
|
31
31
|
status: 200,
|
|
32
32
|
statusText: 'OK',
|
|
33
33
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
34
|
-
json: async () => [],
|
|
34
|
+
json: async () => String(url).includes('/agent-instructions') ? { instructions: '', rulesHash: 'empty', requireSessionStart: false } : [],
|
|
35
35
|
text: async () => '[]',
|
|
36
36
|
}));
|
|
37
37
|
}
|
|
@@ -54,6 +54,24 @@ function firstText(res) {
|
|
|
54
54
|
return content[0]?.text ?? '';
|
|
55
55
|
}
|
|
56
56
|
describe('ORB-1331 - HTTP transport (per-session nudge)', () => {
|
|
57
|
+
it('keeps an unavailable connect policy gated until rules are loaded successfully', async () => {
|
|
58
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('secret offline'));
|
|
59
|
+
const client = await connectSession();
|
|
60
|
+
try {
|
|
61
|
+
const blocked = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
62
|
+
expect(blocked.isError).toBe(true);
|
|
63
|
+
const failed = await client.callTool({ name: 'orboto_session_start', arguments: { rulesOnly: true } });
|
|
64
|
+
expect(failed.isError).toBe(true);
|
|
65
|
+
expect(firstText(failed)).not.toContain('secret');
|
|
66
|
+
mockApi();
|
|
67
|
+
const recovered = await client.callTool({ name: 'orboto_session_start', arguments: { rulesOnly: true } });
|
|
68
|
+
expect(recovered.isError).not.toBe(true);
|
|
69
|
+
expect((await client.callTool({ name: 'orboto_list_projects', arguments: {} })).isError).not.toBe(true);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await client.close();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
57
75
|
it('prepends the nudge exactly once when the first call is orboto_list_projects; the second call is clean', async () => {
|
|
58
76
|
const client = await connectSession();
|
|
59
77
|
const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
@@ -41,6 +41,12 @@ import { buildOrbotoMcpServer } from './server.js';
|
|
|
41
41
|
* also just good UI hygiene - a title that long is a description. */
|
|
42
42
|
const TITLE_MAX = 64;
|
|
43
43
|
let tools;
|
|
44
|
+
function unsafeTakeoverTools(list) {
|
|
45
|
+
return list
|
|
46
|
+
.filter((tool) => Object.prototype.hasOwnProperty.call(tool.inputSchema?.properties ?? {}, 'takeover'))
|
|
47
|
+
.filter((tool) => tool.annotations?.readOnlyHint !== false || tool.annotations?.destructiveHint !== true)
|
|
48
|
+
.map((tool) => tool.name);
|
|
49
|
+
}
|
|
44
50
|
beforeAll(async () => {
|
|
45
51
|
// Port 1 is never listened on -> the connect-time /agent-instructions
|
|
46
52
|
// fetch fails immediately and the server falls back to its built-in
|
|
@@ -84,6 +90,22 @@ describe('MCP tool safety annotations (ORB-1669)', () => {
|
|
|
84
90
|
.map((t) => t.name);
|
|
85
91
|
expect(offenders, `Contradictory annotations on: ${offenders.join(', ')}`).toEqual([]);
|
|
86
92
|
});
|
|
93
|
+
it('every takeover-capable tool is a destructive write for all argument combinations', () => {
|
|
94
|
+
const capable = tools.filter((tool) => Object.prototype.hasOwnProperty.call(tool.inputSchema?.properties ?? {}, 'takeover'));
|
|
95
|
+
// Pin the actual siblings so losing schema discovery cannot pass vacuously.
|
|
96
|
+
expect(capable.map((tool) => tool.name)).toEqual(expect.arrayContaining(['orboto_work_start', 'orboto_work_session_start']));
|
|
97
|
+
expect(unsafeTakeoverTools(tools)).toEqual([]);
|
|
98
|
+
// Reservation without a takeover path is not an account-state takeover.
|
|
99
|
+
expect(tools.find((tool) => tool.name === 'orboto_work_next')?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
|
|
100
|
+
});
|
|
101
|
+
it('the takeover ratchet catches missing, false and contradictory hints on future tools', () => {
|
|
102
|
+
const future = { name: 'future_takeover', inputSchema: { properties: { takeover: { type: 'boolean' } } } };
|
|
103
|
+
for (const annotations of [undefined, { readOnlyHint: false }, { readOnlyHint: false, destructiveHint: false }, { readOnlyHint: true, destructiveHint: true }]) {
|
|
104
|
+
expect(unsafeTakeoverTools([{ ...future, annotations }])).toEqual(['future_takeover']);
|
|
105
|
+
}
|
|
106
|
+
expect(unsafeTakeoverTools([{ ...future, annotations: { readOnlyHint: false, destructiveHint: true } }])).toEqual([]);
|
|
107
|
+
expect(unsafeTakeoverTools([{ name: 'ordinary_read', annotations: { readOnlyHint: true } }])).toEqual([]);
|
|
108
|
+
});
|
|
87
109
|
it(`every tool has a human-readable title of at most ${TITLE_MAX} characters`, () => {
|
|
88
110
|
const missing = tools.filter((t) => !(t.title ?? t.annotations?.title)).map((t) => t.name);
|
|
89
111
|
expect(missing, `Tools with no title: ${missing.join(', ')}`).toEqual([]);
|
package/dist/tool-docs.test.js
CHANGED
|
@@ -55,6 +55,7 @@ describe('orboto_help handler', () => {
|
|
|
55
55
|
});
|
|
56
56
|
describe('live manifest vs help registry (no guidance lost)', () => {
|
|
57
57
|
it('every tool in the full manifest has a one-line wire description and full docs behind orboto_help', async () => {
|
|
58
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ instructions: '', rulesHash: 'empty', requireSessionStart: false }), { status: 200 }));
|
|
58
59
|
const server = await buildOrbotoMcpServer({
|
|
59
60
|
baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'full',
|
|
60
61
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Shared rendering vocabulary for session-start and work-session bundles.
|
|
2
|
+
export const GIT_HEALTH_REASON_TEXT = {
|
|
3
|
+
connection_inactive: 'connection is deactivated',
|
|
4
|
+
app_installation_suspended: 'GitHub App installation is suspended',
|
|
5
|
+
oauth_token_expired: 'OAuth token expired with no refresh path',
|
|
6
|
+
history_backfill_error: 'last history backfill failed',
|
|
7
|
+
awaiting_first_event: 'webhook installed but has never delivered an event',
|
|
8
|
+
outbound_unreachable: 'orboto cannot reach the provider',
|
|
9
|
+
delivery_failing: 'webhook deliveries from the provider are not arriving',
|
|
10
|
+
};
|