@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.
@@ -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.get('/agent-instructions').catch(() => ({ instructions: '' }));
45
- const rules = res?.instructions?.trim() || '(no workspace rules configured)';
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
  };
@@ -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, workNextToolConfig, makeWorkNextHandler, } from './tools/work-sessions.js';
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). Best-effort:
174
- // fall back to the built-in rules if the instance predates ORB-1086
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). Default off; a fetch failure keeps it off.
180
- let requireSessionStart = false;
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.get(`/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
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?.requireSessionStart === true;
192
+ requireSessionStart = res.requireSessionStart ?? true;
192
193
  }
193
194
  catch {
194
- // keep the fallback
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
@@ -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
- * ORB-1471 - advance the gate state for one dispatch and report whether this
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. Calling `orboto_session_start` marks the session unlocked
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
- * Idempotent bookkeeping: running `orboto_session_start` flips
63
- * `sessionStartRan` so all later calls pass.
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
- // The rule-loading tool always runs, and running it unlocks the session.
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
- // session_start itself is never gated AND unlocks the session.
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('session_start as the very first call unlocks immediately (one-call cost avoided)', () => {
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
- // calls are all .catch-guarded so [] is harmless.
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([]);
@@ -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
+ };