@orboto/mcp 0.178.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/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1173 - guard against version literals drifting from package.json.
|
|
3
|
+
*
|
|
4
|
+
* Version used to be hardcoded ('0.51.0') in three spots and release.mjs
|
|
5
|
+
* never touched them, so serverInfo + the User-Agent lied about the real
|
|
6
|
+
* release. Now everything reads VERSION (← package.json, ORB-1166). This
|
|
7
|
+
* test fails CI if (a) VERSION stops matching package.json, or (b) someone
|
|
8
|
+
* reintroduces a hardcoded semver string literal anywhere in src.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { VERSION } from './version.js';
|
|
16
|
+
const SRC = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
18
|
+
// version.ts legitimately holds the '0.0.0' runtime fallback literal.
|
|
19
|
+
const ALLOWED = new Set(['version.ts']);
|
|
20
|
+
function walk(dir, acc = []) {
|
|
21
|
+
for (const entry of readdirSync(dir)) {
|
|
22
|
+
const full = join(dir, entry);
|
|
23
|
+
if (statSync(full).isDirectory())
|
|
24
|
+
walk(full, acc);
|
|
25
|
+
else if (/\.tsx?$/.test(entry) && !entry.endsWith('.test.ts') && !entry.endsWith('.test.tsx') && !ALLOWED.has(entry)) {
|
|
26
|
+
acc.push(full);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return acc;
|
|
30
|
+
}
|
|
31
|
+
describe('ORB-1173 - version literal guard', () => {
|
|
32
|
+
it('VERSION matches package.json (and is not the runtime fallback)', () => {
|
|
33
|
+
expect(VERSION).toBe(pkg.version);
|
|
34
|
+
expect(VERSION).not.toBe('0.0.0');
|
|
35
|
+
});
|
|
36
|
+
it('no hardcoded semver string literal in src (use VERSION instead)', () => {
|
|
37
|
+
const offenders = [];
|
|
38
|
+
// A quoted X.Y.Z literal - what a drifting hardcoded version looks
|
|
39
|
+
// like. Comments (// v0.110.1) and ticket refs (ORB-1166) are not
|
|
40
|
+
// quoted semver, so they don't trip this.
|
|
41
|
+
const semverLiteral = /['"]\d+\.\d+\.\d+['"]/;
|
|
42
|
+
for (const file of walk(SRC)) {
|
|
43
|
+
const text = readFileSync(file, 'utf8');
|
|
44
|
+
text.split('\n').forEach((line, i) => {
|
|
45
|
+
if (semverLiteral.test(line))
|
|
46
|
+
offenders.push(`${file.replace(SRC, 'src')}:${i + 1}: ${line.trim()}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
expect(offenders, `Hardcoded version literal(s) found - read VERSION from version.ts instead:\n${offenders.join('\n')}`).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1166 - single source of truth for the MCP server version.
|
|
3
|
+
*
|
|
4
|
+
* Read from package.json (which release.mjs bumps) so serverInfo.version
|
|
5
|
+
* + the User-Agent header always reflect the real release instead of a
|
|
6
|
+
* hardcoded literal that drifts.
|
|
7
|
+
*
|
|
8
|
+
* WRAPPED IN try/catch on purpose: the Docker runtime image is stripped
|
|
9
|
+
* to `dist/` + `node_modules/` and may not ship package.json at the path
|
|
10
|
+
* `createRequire` resolves. A bare `require('../package.json')` at module
|
|
11
|
+
* load threw there and crash-looped the MCP container (took prod down in
|
|
12
|
+
* v0.110.1). The catch guarantees this module can NEVER crash the server
|
|
13
|
+
* on boot; the Dockerfile now also copies package.json so the real
|
|
14
|
+
* version resolves in-container.
|
|
15
|
+
*/
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
function readVersion() {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
20
|
+
if (typeof pkg?.version === 'string' && pkg.version)
|
|
21
|
+
return pkg.version;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// package.json not present in this runtime - fall through to the default.
|
|
25
|
+
}
|
|
26
|
+
return '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
export const VERSION = readVersion();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1733 - the pending-mail nudge must NEVER touch structuredContent.
|
|
3
|
+
*
|
|
4
|
+
* The SDK validates structuredContent against the tool's declared
|
|
5
|
+
* outputSchema; the ORB-1727 version injected `__pendingAgentMessages`
|
|
6
|
+
* into every result while mail was pending, which failed validation on
|
|
7
|
+
* every strict-schema tool and rendered the whole MCP surface unusable
|
|
8
|
+
* for that identity until the mail was acked. The nudge lives only in a
|
|
9
|
+
* text content block.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { appendMailNudge } from './with-metrics.js';
|
|
13
|
+
function clientWithMail(count) {
|
|
14
|
+
return { pendingAgentMail: count };
|
|
15
|
+
}
|
|
16
|
+
function structuredResult() {
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: 'text', text: 'projects listed' }],
|
|
19
|
+
structuredContent: { projects: [{ key: 'ORB', name: 'orboto' }] },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
describe('ORB-1733: mail nudge vs output schemas', () => {
|
|
23
|
+
it('leaves structuredContent byte-identical while mail is pending - the nudge is a text block only', () => {
|
|
24
|
+
const original = structuredResult();
|
|
25
|
+
const out = appendMailNudge(clientWithMail(2), 'orboto_list_projects', original);
|
|
26
|
+
expect(out.structuredContent).toEqual({ projects: [{ key: 'ORB', name: 'orboto' }] });
|
|
27
|
+
expect(JSON.stringify(out.structuredContent)).not.toContain('__pendingAgentMessages');
|
|
28
|
+
const texts = out.content.filter((c) => c.type === 'text').map((c) => c.text);
|
|
29
|
+
expect(texts.some((t) => t.includes('2 unread agent message'))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('no mail = result passed through untouched', () => {
|
|
32
|
+
const original = structuredResult();
|
|
33
|
+
const out = appendMailNudge(clientWithMail(0), 'orboto_list_projects', original);
|
|
34
|
+
expect(out).toBe(original);
|
|
35
|
+
});
|
|
36
|
+
it('orboto_messages itself is never nudged', () => {
|
|
37
|
+
const original = structuredResult();
|
|
38
|
+
const out = appendMailNudge(clientWithMail(5), 'orboto_messages', original);
|
|
39
|
+
expect(out).toBe(original);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1738 - declared output schemas ADVERTISE the response budget's
|
|
3
|
+
* `__truncation` marker.
|
|
4
|
+
*
|
|
5
|
+
* Second instance of the ORB-1733 class: the budget injects the marker
|
|
6
|
+
* into structuredContent, the SDK advertises output schemas with
|
|
7
|
+
* additionalProperties:false, and strict clients validate structured
|
|
8
|
+
* content against that - so every over-budget response of a tool with a
|
|
9
|
+
* declared outputSchema was rejected wholesale (orboto_list_projects
|
|
10
|
+
* failed on EVERY call of this 57-project workspace; reported via the
|
|
11
|
+
* agent inbox 2026-08-28).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { listProjectsToolConfig } from './tools/list-projects.js';
|
|
15
|
+
import { TruncationBlockSchema, TruncationBlockAdvertisedSchema, applyResponseBudget, resetPayloadStore, } from './response-budget.js';
|
|
16
|
+
/** Mirrors the reg() transformation in with-metrics.ts (ORB-1805: the
|
|
17
|
+
* advertised form is the compact, open one). */
|
|
18
|
+
function advertisedOutputShape(shape) {
|
|
19
|
+
return { ...shape, __truncation: TruncationBlockAdvertisedSchema.optional() };
|
|
20
|
+
}
|
|
21
|
+
/** What additionalProperties:false enforces on a strict client: every key
|
|
22
|
+
* of the payload must be a declared property. */
|
|
23
|
+
function strictClientAccepts(shape, payload) {
|
|
24
|
+
return Object.keys(payload).every((k) => k in shape);
|
|
25
|
+
}
|
|
26
|
+
describe('ORB-1738 - __truncation is part of the advertised output schema', () => {
|
|
27
|
+
it('list_projects: an over-budget payload with the marker passes the strict-client check', () => {
|
|
28
|
+
resetPayloadStore();
|
|
29
|
+
const bigResult = {
|
|
30
|
+
content: [{ type: 'text', text: 'projects' }],
|
|
31
|
+
structuredContent: {
|
|
32
|
+
projects: Array.from({ length: 60 }, (_, i) => ({
|
|
33
|
+
id: `id-${i}`, key: `P${i}`, name: `Project ${i} ${'x'.repeat(120)}`,
|
|
34
|
+
status: 'active', description: 'y'.repeat(120),
|
|
35
|
+
})),
|
|
36
|
+
total: 60,
|
|
37
|
+
totalProjects: 60,
|
|
38
|
+
query: null,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const { result } = applyResponseBudget('orboto_list_projects', bigResult);
|
|
42
|
+
const sc = result.structuredContent;
|
|
43
|
+
// The budget DID cut and marked it.
|
|
44
|
+
expect(sc.__truncation).toBeDefined();
|
|
45
|
+
const shape = advertisedOutputShape(listProjectsToolConfig.outputSchema);
|
|
46
|
+
// Before the fix this was false - the marker was undeclared.
|
|
47
|
+
expect(strictClientAccepts(shape, sc)).toBe(true);
|
|
48
|
+
// And the marker itself matches BOTH the exact runtime shape and the
|
|
49
|
+
// compact schema actually advertised on the wire (ORB-1805).
|
|
50
|
+
expect(TruncationBlockSchema.safeParse(sc.__truncation).success).toBe(true);
|
|
51
|
+
expect(TruncationBlockAdvertisedSchema.safeParse(sc.__truncation).success).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
it('ORB-1805 - the advertised marker schema is strictly more permissive than the exact one', () => {
|
|
54
|
+
// Anything the exact shape accepts, the advertised one must accept -
|
|
55
|
+
// otherwise the diet would start rejecting valid payloads.
|
|
56
|
+
const block = {
|
|
57
|
+
handle: 'h1', budgetChars: 4000, originalChars: 9000, omittedChars: 5000,
|
|
58
|
+
omitted: [{ path: 'items', kind: 'array', omittedItems: 12, keptItems: 3 }],
|
|
59
|
+
howToGetTheRest: 'call orboto_response_expand',
|
|
60
|
+
};
|
|
61
|
+
expect(TruncationBlockSchema.safeParse(block).success).toBe(true);
|
|
62
|
+
expect(TruncationBlockAdvertisedSchema.safeParse(block).success).toBe(true);
|
|
63
|
+
// A future field added to the runtime block must not break clients.
|
|
64
|
+
expect(TruncationBlockAdvertisedSchema.safeParse({ ...block, futureField: 1 }).success).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it('the budget layer adds exactly ONE key - __truncation - and nothing else (class sweep)', () => {
|
|
67
|
+
resetPayloadStore();
|
|
68
|
+
const original = {
|
|
69
|
+
content: [{ type: 'text', text: 'x' }],
|
|
70
|
+
structuredContent: {
|
|
71
|
+
projects: Array.from({ length: 40 }, (_, i) => ({ id: `p-${i}`, key: `K${i}`, name: 'n'.repeat(150), status: 'active', description: 'd'.repeat(150) })),
|
|
72
|
+
total: 40, totalProjects: 40, query: null,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
const before = Object.keys(original.structuredContent);
|
|
76
|
+
const { result } = applyResponseBudget('orboto_list_projects', original);
|
|
77
|
+
const after = Object.keys(result.structuredContent);
|
|
78
|
+
expect(after.filter((k) => !before.includes(k))).toEqual(['__truncation']);
|
|
79
|
+
});
|
|
80
|
+
it('under-budget responses stay byte-identical (no marker, still schema-clean)', () => {
|
|
81
|
+
resetPayloadStore();
|
|
82
|
+
const small = {
|
|
83
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
84
|
+
structuredContent: { projects: [], total: 0, totalProjects: 0, query: null },
|
|
85
|
+
};
|
|
86
|
+
const { result } = applyResponseBudget('orboto_list_projects', small);
|
|
87
|
+
const sc = result.structuredContent;
|
|
88
|
+
expect(sc.__truncation).toBeUndefined();
|
|
89
|
+
expect(strictClientAccepts(advertisedOutputShape(listProjectsToolConfig.outputSchema), sc)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { OrbotoApiError } from './orboto-client.js';
|
|
2
|
+
import { shouldNudge, prependNudge, shouldGate, gateResult } from './session-nudge.js';
|
|
3
|
+
import { applyResponseBudget, TruncationBlockAdvertisedSchema } from './response-budget.js';
|
|
4
|
+
import { buildStrictInputSchema, isRawShape } from './input-schema.js';
|
|
5
|
+
import { captureToolDoc, summarizeToolDescription } from './tool-docs.js';
|
|
6
|
+
import { postLogEntry, redactSecrets } from './mcp-instrument.js';
|
|
7
|
+
/**
|
|
8
|
+
* ORB-1174 - turn an OrbotoApiError into an actionable, agent-visible
|
|
9
|
+
* tool-error message. Before this, a thrown error reached the MCP runtime
|
|
10
|
+
* as a generic "Error occurred during tool execution" - an MCP-only agent
|
|
11
|
+
* couldn't tell 401 (auth) from 404 (not found) from 500 (server) and so
|
|
12
|
+
* couldn't self-correct. We surface the status + a one-line hint + the
|
|
13
|
+
* API's own message. The API error body is workspace error text (no
|
|
14
|
+
* secrets); we still cap its length defensively.
|
|
15
|
+
*/
|
|
16
|
+
function formatApiError(err) {
|
|
17
|
+
let detail = err.body || '';
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(err.body);
|
|
20
|
+
if (parsed.error)
|
|
21
|
+
detail = parsed.error;
|
|
22
|
+
}
|
|
23
|
+
catch { /* body wasn't JSON - use it raw */ }
|
|
24
|
+
detail = detail.slice(0, 400);
|
|
25
|
+
const hint = err.status === 401 ? 'Authentication failed - your token is invalid or expired. Re-authenticate (re-run the OAuth connect, or check the API key).'
|
|
26
|
+
: err.status === 403 ? 'Permission denied - your account lacks the required permission for this action.'
|
|
27
|
+
: err.status === 404 ? 'Not found - the referenced ticket / project / resource does not exist or you cannot see it.'
|
|
28
|
+
: err.status === 409 ? 'Conflict - the resource already exists or is in a state that blocks this change.'
|
|
29
|
+
: err.status === 422 ? 'Validation failed - the request was understood but rejected; adjust the input.'
|
|
30
|
+
: err.status === 429 ? 'Rate limited - slow down and retry shortly.'
|
|
31
|
+
: err.status >= 500 ? 'orboto server error - transient; retry shortly. If it persists the API may be mid-deploy.'
|
|
32
|
+
: 'Request rejected.';
|
|
33
|
+
return `orboto API error ${err.status}. ${hint}\nDetail: ${detail || '(no message)'}`;
|
|
34
|
+
}
|
|
35
|
+
// ORB-1817 - McpLogEntry / redactSecrets / postLogEntry live in
|
|
36
|
+
// mcp-instrument.ts (a leaf module) so input-schema.ts can log validation
|
|
37
|
+
// failures through the same instrument path without an import cycle
|
|
38
|
+
// (this file already imports FROM input-schema.ts).
|
|
39
|
+
/**
|
|
40
|
+
* Wrap a CallToolResult-returning handler so every invocation posts
|
|
41
|
+
* one row to mcp_call_log via /admin/mcp/instrument.
|
|
42
|
+
*
|
|
43
|
+
* Used as: `withMetrics(client, 'orboto_get_ticket', clientHint, makeGetTicketHandler(client))`
|
|
44
|
+
*
|
|
45
|
+
* Wired centrally in `server.ts` so per-tool files don't need to
|
|
46
|
+
* know about instrumentation.
|
|
47
|
+
*/
|
|
48
|
+
export function withMetrics(client, toolName, clientHint,
|
|
49
|
+
// ORB-1252 - forward the SDK's `extra` (RequestHandlerExtra) so handlers can
|
|
50
|
+
// read the per-connection MCP sessionId (distinct per HTTP client even on a
|
|
51
|
+
// shared server). Backward-compatible: handlers that ignore it are unaffected.
|
|
52
|
+
handler,
|
|
53
|
+
// ORB-1331 - per-session/-process nudge state. When present, the first
|
|
54
|
+
// tool dispatch that is not `orboto_session_start` gets the one-time
|
|
55
|
+
// reminder prepended to its response. Optional so direct callers/tests
|
|
56
|
+
// that don't care are unaffected.
|
|
57
|
+
nudge) {
|
|
58
|
+
return async (args, extra) => {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
// ORB-1331 - decide (and advance the flag) once per dispatch, before
|
|
61
|
+
// the handler runs, so the "first tool call" is the first dispatch
|
|
62
|
+
// regardless of its outcome. Applied to the returned result below.
|
|
63
|
+
const wantsNudge = nudge ? shouldNudge(nudge, toolName) : false;
|
|
64
|
+
// ORB-1471 - HARD session-start gate. When the workspace requires it,
|
|
65
|
+
// refuse every tool call until `orboto_session_start` has run (this also
|
|
66
|
+
// flips the "ran" flag when the tool IS session_start). The gate message
|
|
67
|
+
// supersedes the soft nudge, so we return it directly without running the
|
|
68
|
+
// handler. Disabled by default => `shouldGate` returns false and nothing
|
|
69
|
+
// below changes.
|
|
70
|
+
if (nudge && shouldGate(nudge, toolName)) {
|
|
71
|
+
const gated = gateResult();
|
|
72
|
+
void postLogEntry(client, {
|
|
73
|
+
toolName,
|
|
74
|
+
durationMs: Date.now() - start,
|
|
75
|
+
success: false,
|
|
76
|
+
errorMessage: 'session-start gate: call orboto_session_start first',
|
|
77
|
+
clientHint,
|
|
78
|
+
});
|
|
79
|
+
return gated;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const handlerResult = await handler(args, extra);
|
|
83
|
+
// ORB-1697 - the central response budget. Applied HERE, once, so it
|
|
84
|
+
// covers every registered tool instead of relying on 168 handlers to
|
|
85
|
+
// each stay small. Over-budget payloads come back truncated with an
|
|
86
|
+
// explicit `__truncation` block + a handle for the remainder; the
|
|
87
|
+
// measured sizes go into the call log either way.
|
|
88
|
+
const budgeted = applyResponseBudget(toolName, handlerResult);
|
|
89
|
+
const result = budgeted.result;
|
|
90
|
+
// Success path - but the handler can also signal a "soft"
|
|
91
|
+
// failure via { isError: true } in the result. We treat that
|
|
92
|
+
// as success=false in the log so dashboards reflect actual
|
|
93
|
+
// user-visible failures.
|
|
94
|
+
const isError = result.isError === true;
|
|
95
|
+
void postLogEntry(client, {
|
|
96
|
+
toolName,
|
|
97
|
+
durationMs: Date.now() - start,
|
|
98
|
+
success: !isError,
|
|
99
|
+
errorMessage: isError && result.content[0] && 'text' in result.content[0]
|
|
100
|
+
? redactSecrets(String(result.content[0].text)).slice(0, 500)
|
|
101
|
+
: undefined,
|
|
102
|
+
clientHint,
|
|
103
|
+
responseChars: budgeted.responseChars,
|
|
104
|
+
truncatedChars: budgeted.truncatedChars,
|
|
105
|
+
});
|
|
106
|
+
// ORB-1727 - agent-mail piggyback: the client captured the api's
|
|
107
|
+
// `x-orboto-agent-mail` header on the request this tool just made.
|
|
108
|
+
// Append a compact pointer while mail is pending - zero bytes when
|
|
109
|
+
// the inbox is empty, and never on the messages tool itself (the
|
|
110
|
+
// caller is already fetching).
|
|
111
|
+
const withMail = appendMailNudge(client, toolName, result);
|
|
112
|
+
return wantsNudge ? prependNudge(withMail) : withMail;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const durationMs = Date.now() - start;
|
|
116
|
+
// ORB-1174 - an OrbotoApiError carries a real HTTP status + message.
|
|
117
|
+
// Return it as a structured isError result so the agent + human see
|
|
118
|
+
// WHY (401 vs 404 vs 500) and can self-correct, instead of the
|
|
119
|
+
// runtime's opaque "Error occurred during tool execution".
|
|
120
|
+
if (err instanceof OrbotoApiError) {
|
|
121
|
+
const text = formatApiError(err);
|
|
122
|
+
void postLogEntry(client, { toolName, durationMs, success: false, statusCode: err.status, errorMessage: redactSecrets(text).slice(0, 500), clientHint });
|
|
123
|
+
// Still surface the reminder alongside the actionable error so a
|
|
124
|
+
// first-call failure doesn't swallow the one-time nudge.
|
|
125
|
+
const errResult = { isError: true, content: [{ type: 'text', text }] };
|
|
126
|
+
return wantsNudge ? prependNudge(errResult) : errResult;
|
|
127
|
+
}
|
|
128
|
+
// Anything else is unexpected (a bug, not an API rejection) - log
|
|
129
|
+
// and re-throw so it surfaces loudly rather than being swallowed.
|
|
130
|
+
void postLogEntry(client, {
|
|
131
|
+
toolName,
|
|
132
|
+
durationMs,
|
|
133
|
+
success: false,
|
|
134
|
+
errorMessage: redactSecrets(err instanceof Error ? err.message : String(err)).slice(0, 500),
|
|
135
|
+
clientHint,
|
|
136
|
+
});
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Both `config` and the registerTool call use loose types - the SDK
|
|
143
|
+
* has multiple overloads (with / without input schema) and threading
|
|
144
|
+
* generic params through the wrapper makes TS pick the wrong overload
|
|
145
|
+
* for some tools (the admin ones in this case land on the
|
|
146
|
+
* no-input-schema overload). Type-correctness is enforced at the
|
|
147
|
+
* original tool-config declaration site instead.
|
|
148
|
+
*/
|
|
149
|
+
/**
|
|
150
|
+
* ORB-1727 - append the pending-inbox pointer to a tool result. Reads the
|
|
151
|
+
* count the OrbotoClient captured from the last response header; costs
|
|
152
|
+
* nothing when the inbox is empty and skips the fetch/ack tool itself.
|
|
153
|
+
*/
|
|
154
|
+
/** Exported for the ORB-1733 regression test. */
|
|
155
|
+
export function appendMailNudge(client, toolName, result) {
|
|
156
|
+
if (!client.pendingAgentMail || toolName === 'orboto_messages')
|
|
157
|
+
return result;
|
|
158
|
+
// ORB-1733: the nudge lives ONLY in a text content block. Never touch
|
|
159
|
+
// structuredContent - the SDK validates it against the tool's declared
|
|
160
|
+
// outputSchema, and an injected extra key fails validation and errors
|
|
161
|
+
// the entire tool call for as long as mail sits unread.
|
|
162
|
+
const line = `You have ${client.pendingAgentMail} unread agent message(s) - fetch them with orboto_messages.`;
|
|
163
|
+
return {
|
|
164
|
+
...result,
|
|
165
|
+
content: [...result.content, { type: 'text', text: line }],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function registerWithMetrics(server, client, clientHint,
|
|
169
|
+
// ORB-1331 - shared per-session/-process nudge state. Every tool
|
|
170
|
+
// registered through this closure reads/advances the same flag, so the
|
|
171
|
+
// one-time session-start reminder fires on whichever tool is called
|
|
172
|
+
// first (unless it is `orboto_session_start`).
|
|
173
|
+
nudge) {
|
|
174
|
+
return (toolName, config, handler) => {
|
|
175
|
+
// ORB-1692 - every raw-shape input becomes strict + alias-resolving.
|
|
176
|
+
// Central here so a new tool cannot ship permissive; empty/absent
|
|
177
|
+
// shapes stay untouched (they carry no fields to guard).
|
|
178
|
+
let cfg = config?.inputSchema && isRawShape(config.inputSchema)
|
|
179
|
+
// ORB-1817 - `client` + `clientHint` let a validation failure log
|
|
180
|
+
// through the same instrument path as a handler error (Part C):
|
|
181
|
+
// the SDK validates BEFORE this handler wrapper ever runs, so this
|
|
182
|
+
// is the only place that sees the failure.
|
|
183
|
+
? { ...config, inputSchema: buildStrictInputSchema(toolName, config.inputSchema, client, clientHint) }
|
|
184
|
+
: config;
|
|
185
|
+
// ORB-1738 - every declared output shape ADVERTISES the response
|
|
186
|
+
// budget's optional __truncation marker. The SDK emits
|
|
187
|
+
// additionalProperties:false for output schemas, and strict clients
|
|
188
|
+
// validate structuredContent against that - an over-budget response
|
|
189
|
+
// carrying the (undeclared) marker was rejected wholesale
|
|
190
|
+
// (list_projects failed on EVERY call). Same central place as the
|
|
191
|
+
// input fix so no tool can ship an outputSchema that fights the
|
|
192
|
+
// budget layer.
|
|
193
|
+
if (cfg?.outputSchema && isRawShape(cfg.outputSchema)) {
|
|
194
|
+
cfg = {
|
|
195
|
+
...cfg,
|
|
196
|
+
// ORB-1805 - the ADVERTISED (compact, open) form; the exact
|
|
197
|
+
// shape stays TruncationBlockSchema for runtime assertions.
|
|
198
|
+
outputSchema: { ...cfg.outputSchema, __truncation: TruncationBlockAdvertisedSchema.optional() },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// ORB-1741 - manifest diet: the wire manifest carries a one-sentence
|
|
202
|
+
// summary; the full guidance is captured for orboto_help. Central
|
|
203
|
+
// here so a new tool cannot ship a manifest essay by accident.
|
|
204
|
+
if (typeof cfg?.description === 'string' && cfg.description.length > 0) {
|
|
205
|
+
captureToolDoc(toolName, cfg.description);
|
|
206
|
+
cfg = { ...cfg, description: summarizeToolDescription(toolName, cfg.description) };
|
|
207
|
+
}
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
209
|
+
server.registerTool(toolName, cfg, withMetrics(client, toolName, clientHint, handler, nudge));
|
|
210
|
+
};
|
|
211
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-311 Phase F - withMetrics wrapper unit tests.
|
|
3
|
+
*
|
|
4
|
+
* The wrapper's contract:
|
|
5
|
+
* - Always calls the underlying handler exactly once.
|
|
6
|
+
* - On success: posts {toolName, durationMs, success: true, ...}.
|
|
7
|
+
* - On thrown error: posts success: false + errorMessage, re-throws.
|
|
8
|
+
* - On `result.isError === true`: treats as success: false.
|
|
9
|
+
* - The instrument POST is fire-and-forget - never delays the caller.
|
|
10
|
+
*/
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { OrbotoClient, OrbotoApiError } from './orboto-client.js';
|
|
13
|
+
import { withMetrics } from './with-metrics.js';
|
|
14
|
+
import { createNudgeState, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE } from './session-nudge.js';
|
|
15
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
16
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
17
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
18
|
+
/** Capture every POST body the client makes and let the test inspect them. */
|
|
19
|
+
function captureFetch() {
|
|
20
|
+
const calls = [];
|
|
21
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
22
|
+
const body = init?.body ? JSON.parse(init.body) : undefined;
|
|
23
|
+
calls.push({ url: url.toString(), body });
|
|
24
|
+
return {
|
|
25
|
+
ok: true, status: 201, statusText: 'OK',
|
|
26
|
+
json: async () => ({ id: '00000000-0000-0000-0000-000000000001' }),
|
|
27
|
+
text: async () => '',
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
return calls;
|
|
31
|
+
}
|
|
32
|
+
describe('withMetrics', () => {
|
|
33
|
+
it('logs success: true with measured duration on a normal handler', async () => {
|
|
34
|
+
const calls = captureFetch();
|
|
35
|
+
const wrapped = withMetrics(client, 'orboto_test', undefined, async () => ({
|
|
36
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
37
|
+
}));
|
|
38
|
+
const result = await wrapped({});
|
|
39
|
+
expect(result.content[0]).toEqual({ type: 'text', text: 'ok' });
|
|
40
|
+
// Wait one microtask so the fire-and-forget POST resolves.
|
|
41
|
+
await new Promise((r) => setImmediate(r));
|
|
42
|
+
expect(calls).toHaveLength(1);
|
|
43
|
+
expect(calls[0].url).toContain('/admin/mcp/instrument');
|
|
44
|
+
const body = calls[0].body;
|
|
45
|
+
expect(body.toolName).toBe('orboto_test');
|
|
46
|
+
expect(body.success).toBe(true);
|
|
47
|
+
expect(body.durationMs).toBeGreaterThanOrEqual(0);
|
|
48
|
+
});
|
|
49
|
+
it('logs success: false + errorMessage when the handler throws, then re-throws', async () => {
|
|
50
|
+
const calls = captureFetch();
|
|
51
|
+
const wrapped = withMetrics(client, 'orboto_explode', undefined, async () => {
|
|
52
|
+
throw new Error('kaboom');
|
|
53
|
+
});
|
|
54
|
+
await expect(wrapped({})).rejects.toThrow('kaboom');
|
|
55
|
+
await new Promise((r) => setImmediate(r));
|
|
56
|
+
const body = calls[0].body;
|
|
57
|
+
expect(body.success).toBe(false);
|
|
58
|
+
expect(body.errorMessage).toBe('kaboom');
|
|
59
|
+
});
|
|
60
|
+
// ORB-1174 - an OrbotoApiError becomes a structured, actionable isError
|
|
61
|
+
// result (not the runtime's opaque generic) so the agent can self-correct.
|
|
62
|
+
it('maps OrbotoApiError to a distinct, actionable isError result per status', async () => {
|
|
63
|
+
captureFetch();
|
|
64
|
+
const run = async (status, body) => {
|
|
65
|
+
const wrapped = withMetrics(client, 'orboto_x', undefined, async () => {
|
|
66
|
+
throw new OrbotoApiError(status, body, 'https://orboto.example/x');
|
|
67
|
+
});
|
|
68
|
+
const res = await wrapped({});
|
|
69
|
+
expect(res.isError).toBe(true);
|
|
70
|
+
return res.content[0].text;
|
|
71
|
+
};
|
|
72
|
+
const unauth = await run(401, '{"error":"Invalid API key"}');
|
|
73
|
+
expect(unauth).toContain('401');
|
|
74
|
+
expect(unauth).toMatch(/re-?authenticate/i);
|
|
75
|
+
expect(unauth).toContain('Invalid API key'); // the API's own message
|
|
76
|
+
const notFound = await run(404, '{"error":"Ticket not found"}');
|
|
77
|
+
expect(notFound).toContain('404');
|
|
78
|
+
expect(notFound).toMatch(/not found/i);
|
|
79
|
+
const server = await run(503, 'upstream down');
|
|
80
|
+
expect(server).toContain('503');
|
|
81
|
+
expect(server).toMatch(/retry/i);
|
|
82
|
+
// distinct messages, not the same opaque blob
|
|
83
|
+
expect(unauth).not.toBe(notFound);
|
|
84
|
+
expect(notFound).not.toBe(server);
|
|
85
|
+
});
|
|
86
|
+
it('treats result.isError=true as success: false (soft failure)', async () => {
|
|
87
|
+
const calls = captureFetch();
|
|
88
|
+
const wrapped = withMetrics(client, 'orboto_softfail', undefined, async () => ({
|
|
89
|
+
content: [{ type: 'text', text: 'permission denied' }],
|
|
90
|
+
isError: true,
|
|
91
|
+
}));
|
|
92
|
+
const result = await wrapped({});
|
|
93
|
+
expect(result.isError).toBe(true);
|
|
94
|
+
await new Promise((r) => setImmediate(r));
|
|
95
|
+
const body = calls[0].body;
|
|
96
|
+
expect(body.success).toBe(false);
|
|
97
|
+
expect(body.errorMessage).toBe('permission denied');
|
|
98
|
+
});
|
|
99
|
+
it('threads clientHint into the log entry', async () => {
|
|
100
|
+
const calls = captureFetch();
|
|
101
|
+
const wrapped = withMetrics(client, 'orboto_test', 'cursor', async () => ({
|
|
102
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
103
|
+
}));
|
|
104
|
+
await wrapped({});
|
|
105
|
+
await new Promise((r) => setImmediate(r));
|
|
106
|
+
const body = calls[0].body;
|
|
107
|
+
expect(body.clientHint).toBe('cursor');
|
|
108
|
+
});
|
|
109
|
+
it('does not block when the instrument POST fails - handler still resolves', async () => {
|
|
110
|
+
// First call (the handler-as-fetch?) returns ok; but our handler
|
|
111
|
+
// doesn't fetch; the only fetch the wrapper makes is to /instrument.
|
|
112
|
+
// Mock that one to fail - caller must still get the original
|
|
113
|
+
// result back.
|
|
114
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
115
|
+
ok: false, status: 500, statusText: 'Server Error',
|
|
116
|
+
json: async () => ({ error: 'kaboom' }),
|
|
117
|
+
text: async () => '',
|
|
118
|
+
}));
|
|
119
|
+
const wrapped = withMetrics(client, 'orboto_test', undefined, async () => ({
|
|
120
|
+
content: [{ type: 'text', text: 'survives' }],
|
|
121
|
+
}));
|
|
122
|
+
const result = await wrapped({});
|
|
123
|
+
expect(result.content[0]).toEqual({ type: 'text', text: 'survives' });
|
|
124
|
+
});
|
|
125
|
+
// ORB-1180 - admin-panel visibility: the failure log carries the
|
|
126
|
+
// structured HTTP status, and any secret-shaped text is redacted.
|
|
127
|
+
it('logs the structured statusCode on an OrbotoApiError', async () => {
|
|
128
|
+
const calls = captureFetch();
|
|
129
|
+
const wrapped = withMetrics(client, 'orboto_x', undefined, async () => {
|
|
130
|
+
throw new OrbotoApiError(403, '{"error":"Forbidden"}', 'https://orboto.example/x');
|
|
131
|
+
});
|
|
132
|
+
await wrapped({});
|
|
133
|
+
await new Promise((r) => setImmediate(r));
|
|
134
|
+
const body = calls[0].body;
|
|
135
|
+
expect(body.success).toBe(false);
|
|
136
|
+
expect(body.statusCode).toBe(403);
|
|
137
|
+
expect(body.errorMessage).toContain('403');
|
|
138
|
+
});
|
|
139
|
+
// ORB-1331 - the shared nudge state threads through the wrapper: the
|
|
140
|
+
// first non-session_start dispatch carries the one-time reminder,
|
|
141
|
+
// later dispatches are clean, and a session_start-first flow never
|
|
142
|
+
// sees it. structuredContent is left intact.
|
|
143
|
+
it('prepends the session-start nudge on the first non-session_start dispatch, once', async () => {
|
|
144
|
+
captureFetch();
|
|
145
|
+
const nudge = createNudgeState();
|
|
146
|
+
const handler = async () => ({
|
|
147
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
148
|
+
structuredContent: { a: 1 },
|
|
149
|
+
});
|
|
150
|
+
const list = withMetrics(client, 'orboto_list_projects', undefined, handler, nudge);
|
|
151
|
+
const first = await list({});
|
|
152
|
+
expect(first.content[0].text).toBe(SESSION_START_NUDGE);
|
|
153
|
+
expect(first.content[1].text).toBe('ok');
|
|
154
|
+
expect(first.structuredContent).toEqual({ a: 1 }); // untouched
|
|
155
|
+
const second = await list({});
|
|
156
|
+
expect(second.content[0].text).toBe('ok'); // clean
|
|
157
|
+
});
|
|
158
|
+
it('does not nudge when the first dispatch IS orboto_session_start', async () => {
|
|
159
|
+
captureFetch();
|
|
160
|
+
const nudge = createNudgeState();
|
|
161
|
+
const handler = async () => ({ content: [{ type: 'text', text: 'rules' }] });
|
|
162
|
+
const start = withMetrics(client, 'orboto_session_start', undefined, handler, nudge);
|
|
163
|
+
const list = withMetrics(client, 'orboto_list_projects', undefined, handler, nudge);
|
|
164
|
+
const first = await start({});
|
|
165
|
+
expect(first.content[0].text).toBe('rules'); // no nudge
|
|
166
|
+
const second = await list({});
|
|
167
|
+
expect(second.content[0].text).toBe('rules'); // still clean
|
|
168
|
+
});
|
|
169
|
+
it('surfaces the nudge alongside an OrbotoApiError on a first-call failure', async () => {
|
|
170
|
+
captureFetch();
|
|
171
|
+
const nudge = createNudgeState();
|
|
172
|
+
const wrapped = withMetrics(client, 'orboto_list_projects', undefined, async () => {
|
|
173
|
+
throw new OrbotoApiError(403, '{"error":"Forbidden"}', 'https://orboto.example/x');
|
|
174
|
+
}, nudge);
|
|
175
|
+
const res = await wrapped({});
|
|
176
|
+
expect(res.isError).toBe(true);
|
|
177
|
+
expect(res.content[0].text).toBe(SESSION_START_NUDGE);
|
|
178
|
+
expect(res.content[1].text).toContain('403');
|
|
179
|
+
});
|
|
180
|
+
// ORB-1471 - the HARD session-start gate. When the workspace requires it,
|
|
181
|
+
// the wrapper REFUSES a non-session-start tool without running its handler,
|
|
182
|
+
// returns the instructive gate message, and logs the refusal. A
|
|
183
|
+
// session_start call unlocks the session; after that everything runs.
|
|
184
|
+
it('gates a non-session-start tool until session_start runs, without invoking the handler', async () => {
|
|
185
|
+
const calls = captureFetch();
|
|
186
|
+
const nudge = createNudgeState(true); // gate ON
|
|
187
|
+
const listHandler = vi.fn(async () => ({ content: [{ type: 'text', text: 'projects' }] }));
|
|
188
|
+
const startHandler = vi.fn(async () => ({ content: [{ type: 'text', text: 'rules' }] }));
|
|
189
|
+
const list = withMetrics(client, 'orboto_list_projects', undefined, listHandler, nudge);
|
|
190
|
+
const start = withMetrics(client, 'orboto_session_start', undefined, startHandler, nudge);
|
|
191
|
+
// First call is refused - handler never runs.
|
|
192
|
+
const refused = await list({});
|
|
193
|
+
expect(refused.isError).toBe(true);
|
|
194
|
+
expect(refused.content[0].text).toBe(SESSION_START_GATE_MESSAGE);
|
|
195
|
+
expect(listHandler).not.toHaveBeenCalled();
|
|
196
|
+
await new Promise((r) => setImmediate(r));
|
|
197
|
+
const gateLog = calls.find((c) => c.body?.errorMessage?.includes('session-start gate'));
|
|
198
|
+
expect(gateLog).toBeDefined();
|
|
199
|
+
// Running session_start unlocks the session.
|
|
200
|
+
const rules = await start({});
|
|
201
|
+
expect(rules.content[0].text).toBe('rules');
|
|
202
|
+
expect(startHandler).toHaveBeenCalledTimes(1);
|
|
203
|
+
// Now the previously-gated tool runs normally.
|
|
204
|
+
const ok = await list({});
|
|
205
|
+
expect(ok.content[0].text).toBe('projects');
|
|
206
|
+
expect(listHandler).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
it('does not gate anything when the gate is disabled (default)', async () => {
|
|
209
|
+
captureFetch();
|
|
210
|
+
const nudge = createNudgeState(); // gate OFF
|
|
211
|
+
const handler = vi.fn(async () => ({ content: [{ type: 'text', text: 'ok' }] }));
|
|
212
|
+
const list = withMetrics(client, 'orboto_list_projects', undefined, handler, nudge);
|
|
213
|
+
const res = await list({});
|
|
214
|
+
// Still nudged (soft), but NOT refused - the handler ran.
|
|
215
|
+
expect(res.content[1].text).toBe('ok');
|
|
216
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
it('redacts secret-shaped text from the logged errorMessage', async () => {
|
|
219
|
+
const calls = captureFetch();
|
|
220
|
+
const wrapped = withMetrics(client, 'orboto_x', undefined, async () => {
|
|
221
|
+
throw new Error('failed with token orb_abcdef1234567890 and Bearer eyJabcdefghij.k.l');
|
|
222
|
+
});
|
|
223
|
+
await expect(wrapped({})).rejects.toThrow();
|
|
224
|
+
await new Promise((r) => setImmediate(r));
|
|
225
|
+
const body = calls[0].body;
|
|
226
|
+
expect(body.errorMessage).not.toContain('orb_abcdef1234567890');
|
|
227
|
+
expect(body.errorMessage).toContain('orb_[redacted]');
|
|
228
|
+
expect(body.errorMessage).not.toContain('eyJabcdefghij.k.l');
|
|
229
|
+
});
|
|
230
|
+
});
|