@stigmer/runner 3.9.0 → 3.11.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/README.md +12 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-llm.js +9 -10
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/classify-tool-approvals.d.ts +2 -1
- package/dist/activities/classify-tool-approvals.js +28 -2
- package/dist/activities/classify-tool-approvals.js.map +1 -1
- package/dist/activities/discover-mcp-server.d.ts +32 -0
- package/dist/activities/discover-mcp-server.js +162 -27
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
- package/dist/activities/execute-cursor/approval-state.js +7 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
- package/dist/activities/execute-cursor/attachment-resolver.js +25 -4
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
- package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/env-resolver.js +3 -1
- package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
- package/dist/activities/execute-cursor/error-classifier.js +81 -3
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
- package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
- package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
- package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
- package/dist/activities/execute-cursor/hook-script.js +72 -10
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -1
- package/dist/activities/execute-cursor/index.js +59 -58
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
- package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
- package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
- package/dist/activities/execute-cursor/prompt-builder.js +12 -7
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +4 -1
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/environment.js +3 -1
- package/dist/activities/execute-deep-agent/environment.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +15 -0
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
- package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
- package/dist/activities/execute-deep-agent/setup.js +65 -26
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
- package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/hydrate-workflow-execution.js +3 -1
- package/dist/activities/hydrate-workflow-execution.js.map +1 -1
- package/dist/activities/workflow-event-activities.d.ts +28 -10
- package/dist/activities/workflow-event-activities.js +87 -58
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/claimcheck/payload-codec.js +21 -1
- package/dist/claimcheck/payload-codec.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +9 -4
- package/dist/client/stigmer-client.js +28 -15
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/encryption/config.d.ts +32 -0
- package/dist/encryption/config.js +68 -0
- package/dist/encryption/config.js.map +1 -0
- package/dist/encryption/index.d.ts +3 -0
- package/dist/encryption/index.js +3 -0
- package/dist/encryption/index.js.map +1 -0
- package/dist/encryption/payload-codec.d.ts +41 -0
- package/dist/encryption/payload-codec.js +130 -0
- package/dist/encryption/payload-codec.js.map +1 -0
- package/dist/payload-codecs.d.ts +16 -0
- package/dist/payload-codecs.js +38 -0
- package/dist/payload-codecs.js.map +1 -0
- package/dist/preflight.d.ts +31 -0
- package/dist/preflight.js +43 -0
- package/dist/preflight.js.map +1 -1
- package/dist/runner-manager.js +5 -15
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +5 -16
- package/dist/runner.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +9 -3
- package/dist/shared/approval-policy.js +15 -6
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/attachment-naming.d.ts +53 -0
- package/dist/shared/attachment-naming.js +59 -0
- package/dist/shared/attachment-naming.js.map +1 -0
- package/dist/shared/attachment-vision.d.ts +43 -2
- package/dist/shared/attachment-vision.js +72 -6
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/caller-identity.d.ts +23 -2
- package/dist/shared/caller-identity.js +36 -5
- package/dist/shared/caller-identity.js.map +1 -1
- package/dist/shared/channel-attachment.js +1 -0
- package/dist/shared/channel-attachment.js.map +1 -1
- package/dist/shared/checkpointer/http-saver.d.ts +42 -1
- package/dist/shared/checkpointer/http-saver.js +96 -8
- package/dist/shared/checkpointer/http-saver.js.map +1 -1
- package/dist/shared/conversation-attachment.js +1 -0
- package/dist/shared/conversation-attachment.js.map +1 -1
- package/dist/shared/datastore-attachment.d.ts +50 -7
- package/dist/shared/datastore-attachment.js +93 -11
- package/dist/shared/datastore-attachment.js.map +1 -1
- package/dist/shared/http-retry.d.ts +43 -0
- package/dist/shared/http-retry.js +50 -0
- package/dist/shared/http-retry.js.map +1 -0
- package/dist/shared/llm-backend.d.ts +275 -0
- package/dist/shared/llm-backend.js +425 -0
- package/dist/shared/llm-backend.js.map +1 -0
- package/dist/shared/llm-proxy.d.ts +8 -0
- package/dist/shared/llm-proxy.js +15 -0
- package/dist/shared/llm-proxy.js.map +1 -1
- package/dist/shared/mcp-enabled-tools.d.ts +57 -0
- package/dist/shared/mcp-enabled-tools.js +86 -0
- package/dist/shared/mcp-enabled-tools.js.map +1 -0
- package/dist/shared/mcp-manager.d.ts +17 -2
- package/dist/shared/mcp-manager.js +37 -25
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +39 -2
- package/dist/shared/mcp-resolver.js +38 -2
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/model-client.d.ts +12 -5
- package/dist/shared/model-client.js +138 -18
- package/dist/shared/model-client.js.map +1 -1
- package/dist/shared/model-error.js +198 -5
- package/dist/shared/model-error.js.map +1 -1
- package/dist/shared/model-registry.d.ts +20 -2
- package/dist/shared/model-registry.js +37 -2
- package/dist/shared/model-registry.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +26 -0
- package/dist/shared/plan-mode-permissions.js +28 -0
- package/dist/shared/plan-mode-permissions.js.map +1 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +2 -4
- package/dist/worker.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +18 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
- package/dist/workflows/call-agent-orchestrator.js +1 -0
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/dist/workflows/connect-mcp-server.js +7 -0
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/engine-core.js +23 -2
- package/dist/workflows/engine-core.js.map +1 -1
- package/dist/workflows/execute-from-execution.d.ts +1 -1
- package/dist/workflows/execute-from-execution.js +11 -1
- package/dist/workflows/execute-from-execution.js.map +1 -1
- package/package.json +8 -2
- package/src/__tests__/claimcheck-codec.test.ts +36 -0
- package/src/__tests__/encryption-codec.test.ts +234 -0
- package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
- package/src/__tests__/history-encryption-e2e.test.ts +243 -0
- package/src/__tests__/preflight.test.ts +50 -2
- package/src/activities/__tests__/call-llm.test.ts +75 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
- package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
- package/src/activities/call-llm.ts +9 -16
- package/src/activities/classify-tool-approvals.ts +34 -4
- package/src/activities/discover-mcp-server.ts +190 -32
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +92 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
- package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
- package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
- package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
- package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
- package/src/activities/execute-cursor/approval-state.ts +30 -1
- package/src/activities/execute-cursor/attachment-resolver.ts +38 -3
- package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
- package/src/activities/execute-cursor/env-resolver.ts +3 -1
- package/src/activities/execute-cursor/error-classifier.ts +91 -4
- package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
- package/src/activities/execute-cursor/hook-script.ts +74 -10
- package/src/activities/execute-cursor/index.ts +63 -72
- package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
- package/src/activities/execute-cursor/prompt-builder.ts +34 -9
- package/src/activities/execute-cursor/turn-stream.ts +5 -2
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +132 -8
- package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
- package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
- package/src/activities/execute-deep-agent/environment.ts +3 -1
- package/src/activities/execute-deep-agent/index.ts +20 -0
- package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
- package/src/activities/execute-deep-agent/setup.ts +86 -31
- package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
- package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
- package/src/activities/hydrate-workflow-execution.ts +3 -1
- package/src/activities/workflow-event-activities.ts +96 -69
- package/src/claimcheck/payload-codec.ts +33 -1
- package/src/client/__tests__/stigmer-client.test.ts +8 -8
- package/src/client/stigmer-client.ts +32 -18
- package/src/encryption/config.ts +91 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/payload-codec.ts +152 -0
- package/src/payload-codecs.ts +56 -0
- package/src/preflight.ts +45 -0
- package/src/runner-manager.ts +6 -24
- package/src/runner.ts +6 -25
- package/src/shared/__tests__/approval-policy.test.ts +82 -39
- package/src/shared/__tests__/attachment-naming.test.ts +159 -0
- package/src/shared/__tests__/attachment-vision.test.ts +97 -0
- package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
- package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
- package/src/shared/__tests__/caller-identity.test.ts +25 -0
- package/src/shared/__tests__/channel-attachment.test.ts +1 -1
- package/src/shared/__tests__/connect-backfill.test.ts +1 -0
- package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
- package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
- package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
- package/src/shared/__tests__/foundry-seam.test.ts +482 -0
- package/src/shared/__tests__/http-retry.test.ts +67 -0
- package/src/shared/__tests__/llm-backend.test.ts +616 -0
- package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
- package/src/shared/__tests__/mcp-manager.test.ts +170 -2
- package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
- package/src/shared/__tests__/model-client.test.ts +154 -0
- package/src/shared/__tests__/model-error.test.ts +289 -1
- package/src/shared/__tests__/model-registry.test.ts +71 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
- package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
- package/src/shared/__tests__/vertex-seam.test.ts +295 -0
- package/src/shared/approval-policy.ts +14 -7
- package/src/shared/attachment-naming.ts +78 -0
- package/src/shared/attachment-vision.ts +92 -9
- package/src/shared/caller-identity.ts +40 -5
- package/src/shared/channel-attachment.ts +1 -0
- package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
- package/src/shared/checkpointer/http-saver.ts +117 -9
- package/src/shared/conversation-attachment.ts +1 -0
- package/src/shared/datastore-attachment.ts +106 -11
- package/src/shared/http-retry.ts +50 -0
- package/src/shared/llm-backend.ts +544 -0
- package/src/shared/llm-proxy.ts +15 -0
- package/src/shared/mcp-enabled-tools.ts +105 -0
- package/src/shared/mcp-manager.ts +43 -26
- package/src/shared/mcp-resolver.ts +73 -2
- package/src/shared/model-client.ts +161 -19
- package/src/shared/model-error.ts +222 -4
- package/src/shared/model-registry.ts +50 -2
- package/src/shared/plan-mode-permissions.ts +30 -0
- package/src/worker.ts +4 -5
- package/src/workflow-engine/types.ts +18 -0
- package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
- package/src/workflows/call-agent-orchestrator.ts +10 -0
- package/src/workflows/connect-mcp-server.ts +7 -0
- package/src/workflows/engine-core.ts +23 -2
- package/src/workflows/execute-from-execution.ts +12 -2
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shape-aware run.wait() error extraction (oss#299).
|
|
3
|
+
*
|
|
4
|
+
* A bare String() on a structured error value yields "[object Object]",
|
|
5
|
+
* which end users saw verbatim AND which shadowed every lower-priority
|
|
6
|
+
* classifier source (stream, rejection, conversation introspection) because
|
|
7
|
+
* classification stops at the first non-empty source. These tests pin:
|
|
8
|
+
*
|
|
9
|
+
* - the extractRunErrorSources shape matrix (strings, Errors, field objects,
|
|
10
|
+
* hopeless values)
|
|
11
|
+
* - first-USABLE-candidate chain order (a hopeless object no longer hides a
|
|
12
|
+
* usable string one field later; an empty string no longer short-circuits)
|
|
13
|
+
* - end-to-end: structured errors classify and re-enable fresh-agent retry;
|
|
14
|
+
* hopeless extraction yields to the introspection sources
|
|
15
|
+
* - the "[object Object]" defense-in-depth guard in classifyFromSources
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
extractRunErrorSources,
|
|
21
|
+
synthesizeError,
|
|
22
|
+
shouldRetryWithFreshAgent,
|
|
23
|
+
} from "../error-classifier.js";
|
|
24
|
+
|
|
25
|
+
const FALLBACK = { model: "default", mode: "cloud", agentId: "agent-1" };
|
|
26
|
+
|
|
27
|
+
function base() {
|
|
28
|
+
return {
|
|
29
|
+
sdkResultFields: undefined,
|
|
30
|
+
streamErrorMessage: undefined,
|
|
31
|
+
capturedRejection: undefined,
|
|
32
|
+
isResumedHandle: false,
|
|
33
|
+
fallbackContext: FALLBACK,
|
|
34
|
+
} as const;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A run.wait()-shaped error result carrying the given error-detail fields. */
|
|
38
|
+
function errorResult(fields: Record<string, unknown>): unknown {
|
|
39
|
+
return { id: "run-1", status: "error", ...fields };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const NOTHING = { sdkError: undefined, sdkResultFields: undefined };
|
|
43
|
+
|
|
44
|
+
describe("extractRunErrorSources shape matrix", () => {
|
|
45
|
+
it("routes a plain string to sdkResultFields", () => {
|
|
46
|
+
expect(extractRunErrorSources(errorResult({ result: "rate limit exceeded" })))
|
|
47
|
+
.toEqual({ sdkError: undefined, sdkResultFields: "rate limit exceeded" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("lifts an Error instance into the structured channel", () => {
|
|
51
|
+
expect(extractRunErrorSources(errorResult({ result: new Error("connection lost") })))
|
|
52
|
+
.toEqual({ sdkError: { message: "connection lost" }, sdkResultFields: undefined });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("lifts an Error carrying a code (Node/SDK error shape)", () => {
|
|
56
|
+
const err = Object.assign(new Error("stream torn down"), { code: "unavailable" });
|
|
57
|
+
expect(extractRunErrorSources(errorResult({ result: err })))
|
|
58
|
+
.toEqual({ sdkError: { code: "unavailable", message: "stream torn down" }, sdkResultFields: undefined });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("lifts { code, status, message } from a plain object", () => {
|
|
62
|
+
expect(extractRunErrorSources(errorResult({ error: { code: "unauthenticated", status: 401, message: "bad token" } })))
|
|
63
|
+
.toEqual({
|
|
64
|
+
sdkError: { code: "unauthenticated", status: 401, message: "bad token" },
|
|
65
|
+
sdkResultFields: undefined,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lifts a message-only object", () => {
|
|
70
|
+
expect(extractRunErrorSources(errorResult({ error: { message: "boom" } })))
|
|
71
|
+
.toEqual({ sdkError: { message: "boom" }, sdkResultFields: undefined });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("lifts a code-only object", () => {
|
|
75
|
+
expect(extractRunErrorSources(errorResult({ error: { code: "resource_exhausted" } })))
|
|
76
|
+
.toEqual({ sdkError: { code: "resource_exhausted" }, sdkResultFields: undefined });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("yields nothing for an object with no recognizable fields (no JSON.stringify junk)", () => {
|
|
80
|
+
expect(extractRunErrorSources(errorResult({ result: { weird: "shape" } }))).toEqual(NOTHING);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("yields nothing for a circular object", () => {
|
|
84
|
+
const circular: Record<string, unknown> = {};
|
|
85
|
+
circular.self = circular;
|
|
86
|
+
expect(extractRunErrorSources(errorResult({ result: circular }))).toEqual(NOTHING);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("refuses the '[object Object]' junk string itself", () => {
|
|
90
|
+
expect(extractRunErrorSources(errorResult({ result: "[object Object]" }))).toEqual(NOTHING);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("stringifies non-string primitives losslessly", () => {
|
|
94
|
+
expect(extractRunErrorSources(errorResult({ result: 503 })))
|
|
95
|
+
.toEqual({ sdkError: undefined, sdkResultFields: "503" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("yields nothing when no candidate field is present", () => {
|
|
99
|
+
expect(extractRunErrorSources(errorResult({}))).toEqual(NOTHING);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("yields nothing for non-object results", () => {
|
|
103
|
+
expect(extractRunErrorSources(undefined)).toEqual(NOTHING);
|
|
104
|
+
expect(extractRunErrorSources(null)).toEqual(NOTHING);
|
|
105
|
+
expect(extractRunErrorSources("not-a-result-object")).toEqual(NOTHING);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("extractRunErrorSources chain order (first USABLE candidate wins)", () => {
|
|
110
|
+
it("a hopeless object in result no longer hides a usable string in message", () => {
|
|
111
|
+
const extracted = extractRunErrorSources(
|
|
112
|
+
errorResult({ result: { weird: "shape" }, message: "the real reason" }),
|
|
113
|
+
);
|
|
114
|
+
expect(extracted).toEqual({ sdkError: undefined, sdkResultFields: "the real reason" });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("an empty string in result no longer short-circuits the chain", () => {
|
|
118
|
+
const extracted = extractRunErrorSources(
|
|
119
|
+
errorResult({ result: "", reason: "torn down mid-stream" }),
|
|
120
|
+
);
|
|
121
|
+
expect(extracted).toEqual({ sdkError: undefined, sdkResultFields: "torn down mid-stream" });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("respects the documented field order: result before error before message before reason", () => {
|
|
125
|
+
const extracted = extractRunErrorSources(
|
|
126
|
+
errorResult({ result: "from-result", error: "from-error", message: "from-message" }),
|
|
127
|
+
);
|
|
128
|
+
expect(extracted.sdkResultFields).toBe("from-result");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("yields nothing when every candidate is hopeless", () => {
|
|
132
|
+
const extracted = extractRunErrorSources(
|
|
133
|
+
errorResult({ result: {}, error: "", message: "[object Object]" }),
|
|
134
|
+
);
|
|
135
|
+
expect(extracted).toEqual(NOTHING);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("end-to-end through synthesizeError", () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
142
|
+
});
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
vi.restoreAllMocks();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("a structured retryable error classifies and re-enables fresh-agent recovery", () => {
|
|
148
|
+
// The regression at the heart of oss#299: String() turned this into
|
|
149
|
+
// "[object Object]" -> category=unknown, retryable=false -> the
|
|
150
|
+
// poisoned-handle retry could never fire for a plain network flake.
|
|
151
|
+
const extracted = extractRunErrorSources(
|
|
152
|
+
errorResult({ error: { code: "unavailable", message: "upstream connect error" } }),
|
|
153
|
+
);
|
|
154
|
+
const classified = synthesizeError({ ...base(), ...extracted });
|
|
155
|
+
|
|
156
|
+
expect(classified.source).toBe("sdk");
|
|
157
|
+
expect(classified.category).toBe("network");
|
|
158
|
+
expect(classified.message).toBe("upstream connect error");
|
|
159
|
+
expect(classified.retryable).toBe(true);
|
|
160
|
+
expect(shouldRetryWithFreshAgent(classified)).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("hopeless extraction yields to the conversation introspection source", () => {
|
|
164
|
+
const extracted = extractRunErrorSources(errorResult({ result: { weird: "shape" } }));
|
|
165
|
+
const classified = synthesizeError({
|
|
166
|
+
...base(),
|
|
167
|
+
...extracted,
|
|
168
|
+
conversationErrorText: "grpc-status 12: routing failure",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(classified.source).toBe("conversation");
|
|
172
|
+
expect(classified.message).toBe("grpc-status 12: routing failure");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("hopeless extraction yields to the captured rejection source", () => {
|
|
176
|
+
const extracted = extractRunErrorSources(errorResult({ result: { weird: "shape" } }));
|
|
177
|
+
const classified = synthesizeError({
|
|
178
|
+
...base(),
|
|
179
|
+
...extracted,
|
|
180
|
+
capturedRejection: { code: "unavailable", message: "socket hang up", timestamp: Date.now() },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(classified.source).toBe("rejection");
|
|
184
|
+
expect(classified.message).toContain("socket hang up");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("classifyFromSources '[object Object]' defense-in-depth guard", () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
191
|
+
});
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
vi.restoreAllMocks();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("treats a leaked '[object Object]' sdkResultFields as absent", () => {
|
|
197
|
+
// Extraction never emits it, but any other producer of the junk string
|
|
198
|
+
// must not shadow the sources below it.
|
|
199
|
+
const classified = synthesizeError({
|
|
200
|
+
...base(),
|
|
201
|
+
sdkResultFields: "[object Object]",
|
|
202
|
+
streamErrorMessage: "fetch failed",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(classified.source).toBe("stream");
|
|
206
|
+
expect(classified.category).toBe("network");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import type { Config } from "../../../config.js";
|
|
3
|
+
|
|
4
|
+
vi.mock("../../../shared/model-registry.js", () => ({
|
|
5
|
+
getEconomyModel: vi.fn().mockResolvedValue("gpt-4o-mini"),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
const mockInvoke = vi.fn();
|
|
9
|
+
const mockWithStructuredOutput = vi.fn().mockReturnValue({ invoke: mockInvoke });
|
|
10
|
+
|
|
11
|
+
vi.mock("../../../shared/model-client.js", () => ({
|
|
12
|
+
buildChatModel: vi.fn().mockResolvedValue({
|
|
13
|
+
model: { withStructuredOutput: (...args: unknown[]) => mockWithStructuredOutput(...args) },
|
|
14
|
+
provider: "openai",
|
|
15
|
+
apiModelId: "gpt-4o-mini",
|
|
16
|
+
}),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// llm-backend.js and llm-proxy.js stay real: the pre-check behavior under
|
|
20
|
+
// test IS their composition, and both are pure modules.
|
|
21
|
+
|
|
22
|
+
const SCHEMA = { type: "object", properties: { answer: { type: "string" } } };
|
|
23
|
+
|
|
24
|
+
function makeConfig(overrides: Partial<Config> = {}): Config {
|
|
25
|
+
return {
|
|
26
|
+
proxyEndpoint: null,
|
|
27
|
+
stigmerToken: null,
|
|
28
|
+
...overrides,
|
|
29
|
+
} as Config;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("extractStructuredOutput", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
// Deterministic regardless of the developer's shell: blank reads as
|
|
36
|
+
// missing, and backend vars must not leak in from outside.
|
|
37
|
+
vi.stubEnv("OPENAI_API_KEY", "");
|
|
38
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
|
39
|
+
vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
|
|
40
|
+
vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.unstubAllEnvs();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("direct mode with a key builds a direct-mode model (no endpoint threaded)", async () => {
|
|
48
|
+
vi.stubEnv("OPENAI_API_KEY", "sk-direct");
|
|
49
|
+
const { buildChatModel } = await import("../../../shared/model-client.js");
|
|
50
|
+
const { extractStructuredOutput } = await import("../extract-structured-output.js");
|
|
51
|
+
mockInvoke.mockResolvedValueOnce({ answer: "42" });
|
|
52
|
+
|
|
53
|
+
const result = await extractStructuredOutput("the answer is 42", SCHEMA, makeConfig(), "gpt-4.1");
|
|
54
|
+
|
|
55
|
+
expect(result).toEqual({ answer: "42" });
|
|
56
|
+
// The regression pin: the gRPC control-plane endpoint must never
|
|
57
|
+
// reappear here as a stand-in LLM proxy.
|
|
58
|
+
expect(buildChatModel).toHaveBeenCalledWith(
|
|
59
|
+
expect.objectContaining({ proxyEndpoint: undefined }),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("throws the credential message before any construction when no path exists", async () => {
|
|
64
|
+
const { buildChatModel } = await import("../../../shared/model-client.js");
|
|
65
|
+
const { extractStructuredOutput } = await import("../extract-structured-output.js");
|
|
66
|
+
|
|
67
|
+
await expect(
|
|
68
|
+
extractStructuredOutput("text", SCHEMA, makeConfig(), "gpt-4.1"),
|
|
69
|
+
).rejects.toThrow(/'gpt-4o-mini'.*OPENAI_API_KEY/s);
|
|
70
|
+
expect(buildChatModel).not.toHaveBeenCalled();
|
|
71
|
+
expect(mockInvoke).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("proxy mode threads the proxy endpoint and token, consulting no keys", async () => {
|
|
75
|
+
const { buildChatModel } = await import("../../../shared/model-client.js");
|
|
76
|
+
const { extractStructuredOutput } = await import("../extract-structured-output.js");
|
|
77
|
+
mockInvoke.mockResolvedValueOnce({ answer: "ok" });
|
|
78
|
+
|
|
79
|
+
const result = await extractStructuredOutput(
|
|
80
|
+
"text", SCHEMA,
|
|
81
|
+
makeConfig({ proxyEndpoint: "https://api.stigmer.ai", stigmerToken: "tok" }),
|
|
82
|
+
"gpt-4.1",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(result).toEqual({ answer: "ok" });
|
|
86
|
+
expect(buildChatModel).toHaveBeenCalledWith(
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
proxyEndpoint: "https://api.stigmer.ai",
|
|
89
|
+
stigmerToken: "tok",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("defers an un-inferable extraction model to buildChatModel's own error", async () => {
|
|
95
|
+
// The registry-empty fallback returns the primary model verbatim; when
|
|
96
|
+
// its provider can't be inferred the pre-check must not guess — the
|
|
97
|
+
// construction path owns the precise message.
|
|
98
|
+
const { getEconomyModel } = await import("../../../shared/model-registry.js");
|
|
99
|
+
vi.mocked(getEconomyModel).mockResolvedValueOnce("mystery-model");
|
|
100
|
+
const { buildChatModel } = await import("../../../shared/model-client.js");
|
|
101
|
+
const { extractStructuredOutput } = await import("../extract-structured-output.js");
|
|
102
|
+
mockInvoke.mockResolvedValueOnce({ answer: "ok" });
|
|
103
|
+
|
|
104
|
+
await extractStructuredOutput("text", SCHEMA, makeConfig(), "mystery-model");
|
|
105
|
+
|
|
106
|
+
expect(buildChatModel).toHaveBeenCalledWith(
|
|
107
|
+
expect.objectContaining({ modelName: "mystery-model" }),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("normalizes an empty extraction result to null", async () => {
|
|
112
|
+
vi.stubEnv("OPENAI_API_KEY", "sk-direct");
|
|
113
|
+
const { extractStructuredOutput } = await import("../extract-structured-output.js");
|
|
114
|
+
mockInvoke.mockResolvedValueOnce(undefined);
|
|
115
|
+
|
|
116
|
+
const result = await extractStructuredOutput("text", SCHEMA, makeConfig(), "gpt-4.1");
|
|
117
|
+
|
|
118
|
+
expect(result).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -245,6 +245,99 @@ d("generated approval hook (preToolUse + beforeMCPExecution)", () => {
|
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
+
// The enabled_tools capability manifest (issue #350): mcpServerEnabledTools
|
|
249
|
+
// holds ONLY restricted servers; the hook denies a listed server's
|
|
250
|
+
// non-listed tool with the non-pausing, permanent "disabled" kind — BEFORE
|
|
251
|
+
// autoApproveAll and the grant checks, because a manifest is not an
|
|
252
|
+
// approval gate (nothing may resurrect a disabled tool). hookMcp payloads
|
|
253
|
+
// carry mcp_server_name "srv".
|
|
254
|
+
describe("MCP enabled_tools manifest (beforeMCPExecution, issue #350)", () => {
|
|
255
|
+
it("denies a non-enabled tool with kind disabled (content-free, single record) and the manifest message", () => {
|
|
256
|
+
const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
|
|
257
|
+
|
|
258
|
+
const res = h.decide(hookMcp("click", { app: "Slack" }));
|
|
259
|
+
|
|
260
|
+
expect(res.permission).toBe("deny");
|
|
261
|
+
// Permanent-denial framing, never the approval promise: the model must
|
|
262
|
+
// adapt, not wait for a resume that will never come.
|
|
263
|
+
expect(res.raw).toContain("not enabled for this agent");
|
|
264
|
+
expect(res.raw).not.toContain("submitted to the user for approval");
|
|
265
|
+
const ledger = h.ledger();
|
|
266
|
+
expect(ledger).toHaveLength(1);
|
|
267
|
+
expect(ledger[0].kind).toBe("disabled");
|
|
268
|
+
// Attributable under the MCP name-token (the identity the stream row
|
|
269
|
+
// computes), content-free like every non-approval kind.
|
|
270
|
+
expect(ledger[0].token).toBe(grantToken("click", ""));
|
|
271
|
+
expect(ledger[0]).not.toHaveProperty("input");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("allows an enabled tool on a restricted server", () => {
|
|
275
|
+
const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
|
|
276
|
+
expect(h.decide(hookMcp("list_apps")).permission).toBe("allow");
|
|
277
|
+
expect(h.ledger()).toEqual([]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("denies even under autoApproveAll (a manifest is not an approval gate)", () => {
|
|
281
|
+
const h = setup({
|
|
282
|
+
autoApproveAll: true,
|
|
283
|
+
mcpServerEnabledTools: { srv: ["list_apps"] },
|
|
284
|
+
});
|
|
285
|
+
const res = h.decide(hookMcp("click"));
|
|
286
|
+
expect(res.permission).toBe("deny");
|
|
287
|
+
expect(h.ledger()[0].kind).toBe("disabled");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("denies even when the tool holds a reinvocation grant (no approval may resurrect it)", () => {
|
|
291
|
+
const h = setup({
|
|
292
|
+
mcpServerEnabledTools: { srv: ["list_apps"] },
|
|
293
|
+
grants: [{ toolName: "click", mcpServerSlug: "srv", key: "click", salient: "", contentDigest: "", sourceToolCallId: "consent-1" }],
|
|
294
|
+
});
|
|
295
|
+
const res = h.decide(hookMcp("click"));
|
|
296
|
+
expect(res.permission).toBe("deny");
|
|
297
|
+
expect(h.ledger()[0].kind).toBe("disabled");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("stays kind disabled under unattended mode (mode-independent, like secret)", () => {
|
|
301
|
+
const h = setup({
|
|
302
|
+
unattendedSkip: true,
|
|
303
|
+
mcpServerEnabledTools: { srv: ["list_apps"] },
|
|
304
|
+
});
|
|
305
|
+
const res = h.decide(hookMcp("click"));
|
|
306
|
+
expect(res.permission).toBe("deny");
|
|
307
|
+
expect(h.ledger()[0].kind).toBe("disabled");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("an enabled tool still flows into the normal approval arm (manifest and gate compose)", () => {
|
|
311
|
+
const h = setup({
|
|
312
|
+
mcpPolicies: { click: { requiresApproval: true, message: "Approve click?" } },
|
|
313
|
+
mcpServerEnabledTools: { srv: ["click"] },
|
|
314
|
+
});
|
|
315
|
+
const res = h.decide(hookMcp("click"));
|
|
316
|
+
expect(res.permission).toBe("deny");
|
|
317
|
+
expect(res.raw).toContain("Approve click?");
|
|
318
|
+
expect(h.ledger()[0].kind).toBe("approval");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("a restriction on ANOTHER server never narrows this one (server-scoped matching)", () => {
|
|
322
|
+
const h = setup({ mcpServerEnabledTools: { other: ["something_else"] } });
|
|
323
|
+
expect(h.decide(hookMcp("click")).permission).toBe("allow");
|
|
324
|
+
expect(h.ledger()).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("quoted-name matching is exact — an enabled name never allows its prefix-sibling", () => {
|
|
328
|
+
const h = setup({ mcpServerEnabledTools: { srv: ["list_apps_extended"] } });
|
|
329
|
+
const res = h.decide(hookMcp("list_apps"));
|
|
330
|
+
expect(res.permission).toBe("deny");
|
|
331
|
+
expect(h.ledger()[0].kind).toBe("disabled");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("never gates a preToolUse (built-in) payload — the manifest arm is MCP-event-scoped", () => {
|
|
335
|
+
const h = setup({ mcpServerEnabledTools: { srv: ["list_apps"] } });
|
|
336
|
+
expect(h.decide(hookRead("/x/a.txt")).permission).toBe("allow");
|
|
337
|
+
expect(h.ledger()).toEqual([]);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
248
341
|
// The hook captures the COMPLETE tool_input on every denial (base64(JSON)),
|
|
249
342
|
// so the runner can overlay the proposed change onto the gated tool call for
|
|
250
343
|
// the approval preview — the cursor analog of the native harness reading args
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { resolveMcpServers } from "../mcp-resolver.js";
|
|
3
|
+
|
|
4
|
+
function makeUsage(
|
|
5
|
+
slug: string,
|
|
6
|
+
enabledTools: string[] = [],
|
|
7
|
+
toolApprovalOverrides: Array<{ toolName: string; requiresApproval: boolean }> = [],
|
|
8
|
+
) {
|
|
9
|
+
return {
|
|
10
|
+
mcpServerRef: { slug, org: "test-org", kind: 0 },
|
|
11
|
+
enabledTools,
|
|
12
|
+
toolApprovalOverrides,
|
|
13
|
+
} as any;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function httpMcpServer(slug: string, defaultEnabledTools: string[] = []) {
|
|
17
|
+
return {
|
|
18
|
+
metadata: { id: `id-${slug}`, slug },
|
|
19
|
+
spec: {
|
|
20
|
+
serverType: { case: "http", value: { url: "https://mcp.example.com/mcp", headers: {} } },
|
|
21
|
+
env: {},
|
|
22
|
+
defaultEnabledTools,
|
|
23
|
+
},
|
|
24
|
+
status: undefined,
|
|
25
|
+
} as any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clientReturning(serversBySlug: Record<string, unknown>) {
|
|
29
|
+
return {
|
|
30
|
+
getMcpServerByReference: vi.fn(async (ref: { slug: string }) => {
|
|
31
|
+
const server = serversBySlug[ref.slug];
|
|
32
|
+
if (!server) throw new Error(`not found: ${ref.slug}`);
|
|
33
|
+
return server;
|
|
34
|
+
}),
|
|
35
|
+
} as any;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The effective-list semantics live in shared/mcp-enabled-tools.ts (tested
|
|
39
|
+
// there); these tests pin the CURSOR resolver's threading — the near-duplicate
|
|
40
|
+
// of shared/mcp-resolver.ts that must mirror it until oss#387 consolidates.
|
|
41
|
+
describe("resolveMcpServers (cursor) — enabled_tools threading (issue #350)", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("carries the usage's enabled_tools as the effective allow-list", async () => {
|
|
48
|
+
const client = clientReturning({ github: httpMcpServer("github") });
|
|
49
|
+
|
|
50
|
+
const result = await resolveMcpServers(
|
|
51
|
+
client, [makeUsage("github", ["create_pr"])], {}, "stdio-forbidden",
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(result.resolvedServers[0].enabledTools).toEqual(["create_pr"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("falls back to default_enabled_tools for an empty usage list", async () => {
|
|
58
|
+
const client = clientReturning({
|
|
59
|
+
github: httpMcpServer("github", ["search_code"]),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await resolveMcpServers(
|
|
63
|
+
client, [makeUsage("github")], {}, "stdio-forbidden",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(result.resolvedServers[0].enabledTools).toEqual(["search_code"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("resolves unrestricted (absent field) when both lists are empty", async () => {
|
|
70
|
+
const client = clientReturning({ github: httpMcpServer("github") });
|
|
71
|
+
|
|
72
|
+
const result = await resolveMcpServers(
|
|
73
|
+
client, [makeUsage("github")], {}, "stdio-forbidden",
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(result.resolvedServers[0].enabledTools).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("never narrows the Cursor SDK config — the SDK has no allow-list field; enforcement is the hook's disabled arm", async () => {
|
|
80
|
+
const client = clientReturning({ github: httpMcpServer("github") });
|
|
81
|
+
|
|
82
|
+
const result = await resolveMcpServers(
|
|
83
|
+
client, [makeUsage("github", ["create_pr"])], {}, "stdio-forbidden",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(result.cursorConfig.github).toEqual({
|
|
87
|
+
type: "http",
|
|
88
|
+
url: "https://mcp.example.com/mcp",
|
|
89
|
+
headers: undefined,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("resolveMcpServers (cursor) — tool_approval_overrides threading (issue #349)", () => {
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.restoreAllMocks();
|
|
97
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("carries the usage's overrides on its own resolved server only", async () => {
|
|
101
|
+
// Riding the server is the scoping mechanism: an override can no longer
|
|
102
|
+
// reach a same-named tool on another server, because it never exists
|
|
103
|
+
// anywhere but its own server's object.
|
|
104
|
+
const client = clientReturning({
|
|
105
|
+
github: httpMcpServer("github"),
|
|
106
|
+
slack: httpMcpServer("slack"),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = await resolveMcpServers(
|
|
110
|
+
client,
|
|
111
|
+
[
|
|
112
|
+
makeUsage("github", [], [{ toolName: "delete_item", requiresApproval: false }]),
|
|
113
|
+
makeUsage("slack"),
|
|
114
|
+
],
|
|
115
|
+
{},
|
|
116
|
+
"stdio-forbidden",
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const bySlug = new Map(result.resolvedServers.map((s) => [s.slug, s]));
|
|
120
|
+
expect(bySlug.get("github")!.toolApprovalOverrides).toEqual([
|
|
121
|
+
{ toolName: "delete_item", requiresApproval: false },
|
|
122
|
+
]);
|
|
123
|
+
expect(bySlug.get("slack")!.toolApprovalOverrides).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -222,6 +222,19 @@ describe("consumeCursorTurnStream", () => {
|
|
|
222
222
|
expect(state.streamErrorMessage).toBe("boom");
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
it("does not capture a non-string stream ERROR message (oss#299 hardening)", async () => {
|
|
226
|
+
// message is untyped at runtime; a structured value assigned here would
|
|
227
|
+
// crash classifyText downstream (.toLowerCase() on a non-string).
|
|
228
|
+
const { deps, state } = buildDeps();
|
|
229
|
+
|
|
230
|
+
await consumeCursorTurnStream(
|
|
231
|
+
mockRun([ev({ type: "status", status: "ERROR", message: { code: 14 } })]),
|
|
232
|
+
deps,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(state.streamErrorMessage).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
225
238
|
describe("first-denial early stop", () => {
|
|
226
239
|
let hitlDir: string;
|
|
227
240
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* "mcpToolPolicies": {
|
|
14
14
|
* "apply_cloud_resource": { "requiresApproval": true, "message": "..." }
|
|
15
15
|
* },
|
|
16
|
+
* "mcpServerEnabledTools": { "planton": ["get_cloud_resource"] },
|
|
16
17
|
* "approvedGrants": [{ "toolName": "edit", "mcpServerSlug": "", "key": "write", "salient": "a.txt", "contentDigest": "<sha256>" }],
|
|
17
18
|
* "approvedGrantTokens": ["<base64(key\nsalient[\ncontentDigest])>"]
|
|
18
19
|
* }
|
|
@@ -174,6 +175,21 @@ export interface ApprovalStateFile {
|
|
|
174
175
|
*/
|
|
175
176
|
leasedCategories: string[];
|
|
176
177
|
mcpToolPolicies: Record<string, McpToolPolicyEntry>;
|
|
178
|
+
/**
|
|
179
|
+
* Per-server effective enabled_tools allow-lists (issue #350), keyed by
|
|
180
|
+
* MCP server slug — ONLY restricted servers appear (an absent slug means
|
|
181
|
+
* unrestricted, so the common case stays an empty object). The Cursor SDK
|
|
182
|
+
* config cannot hide a server's tools, so the hook enforces the manifest
|
|
183
|
+
* instead: on beforeMCPExecution it matches the payload's mcp_server_name
|
|
184
|
+
* against this map and denies a non-listed tool with the non-pausing,
|
|
185
|
+
* permanent "disabled" kind — BEFORE autoApproveAll and grants, because
|
|
186
|
+
* enabled_tools is a capability manifest, not an approval gate (no bypass
|
|
187
|
+
* may resurrect a disabled tool, and no human may be offered "approve" on
|
|
188
|
+
* one). Unlike mcpToolPolicies (name-keyed, server-blind), this map is
|
|
189
|
+
* server-scoped: the hook payload carries the server identity, so equal
|
|
190
|
+
* tool names on different servers cannot cross-grant.
|
|
191
|
+
*/
|
|
192
|
+
mcpServerEnabledTools: Record<string, string[]>;
|
|
177
193
|
approvedGrants: ApprovalGrant[];
|
|
178
194
|
approvedGrantTokens: string[];
|
|
179
195
|
/**
|
|
@@ -381,6 +397,8 @@ function parseArgs(argsPreview: string): Record<string, unknown> | undefined {
|
|
|
381
397
|
* - leasedCategories: built-in categories with a run-lifetime lease
|
|
382
398
|
* - mcpToolPolicies: per-tool policy for MCP tools requiring approval (leased
|
|
383
399
|
* servers are already absent — dropped upstream by mergeApprovalPolicies)
|
|
400
|
+
* - mcpServerEnabledTools: per-server enabled_tools allow-lists (issue #350,
|
|
401
|
+
* restricted servers only) for the hook's permanent "disabled" arm
|
|
384
402
|
* - approvedGrants / approvedGrantTokens: tools approved in the current HITL
|
|
385
403
|
* cycle, allowed through on reinvocation
|
|
386
404
|
*
|
|
@@ -396,6 +414,7 @@ export function buildApprovalState(
|
|
|
396
414
|
captureIgnored = false,
|
|
397
415
|
gitWorkspace = true,
|
|
398
416
|
unattendedSkip = false,
|
|
417
|
+
mcpServerEnabledTools: Record<string, string[]> = {},
|
|
399
418
|
): ApprovalStateFile {
|
|
400
419
|
const approvedGrants = grants ?? [];
|
|
401
420
|
|
|
@@ -411,6 +430,7 @@ export function buildApprovalState(
|
|
|
411
430
|
autoApproveAll: globalBypass,
|
|
412
431
|
leasedCategories: [...leasedCategories],
|
|
413
432
|
mcpToolPolicies,
|
|
433
|
+
mcpServerEnabledTools,
|
|
414
434
|
approvedGrants,
|
|
415
435
|
// The hook matches a tool call's PRIMARY token (content when it can compute a
|
|
416
436
|
// digest from tool_input, else coarse). A content-identified grant authorizes
|
|
@@ -474,12 +494,18 @@ const DENIAL_LEDGER_FILE = "denials.jsonl";
|
|
|
474
494
|
* classification may never have run).
|
|
475
495
|
* - `fail-closed` — the approval state file was missing, so everything gated
|
|
476
496
|
* denied. A turn-level "the gate itself was broken" fact.
|
|
497
|
+
* - `disabled` — the agent's enabled_tools manifest excludes this MCP
|
|
498
|
+
* tool (issue #350). Permanent for the run and
|
|
499
|
+
* mode-independent: NOT an approval (a human must never be
|
|
500
|
+
* offered "approve" on a manifest-disabled tool), so it is
|
|
501
|
+
* non-pausing and the model adapts — the same consumer
|
|
502
|
+
* semantics as `secret`.
|
|
477
503
|
*
|
|
478
504
|
* An unknown kind string is preserved as-is: it is treated as non-pausing (an
|
|
479
505
|
* unknown deny must never manufacture an approval) but still attributes the
|
|
480
506
|
* blocked call to our own hook.
|
|
481
507
|
*/
|
|
482
|
-
export type DenialKind = "approval" | "unattended" | "secret" | "capture-error" | "fail-closed";
|
|
508
|
+
export type DenialKind = "approval" | "unattended" | "secret" | "capture-error" | "fail-closed" | "disabled";
|
|
483
509
|
|
|
484
510
|
/** The one kind that pauses the run for user approval. */
|
|
485
511
|
export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
|
|
@@ -487,6 +513,9 @@ export const APPROVAL_DENIAL_KIND: DenialKind = "approval";
|
|
|
487
513
|
/** The unattended-mode resolution kind (non-pausing; stamped SKIPPED). */
|
|
488
514
|
export const UNATTENDED_DENIAL_KIND: DenialKind = "unattended";
|
|
489
515
|
|
|
516
|
+
/** The enabled_tools manifest denial kind (non-pausing, permanent; issue #350). */
|
|
517
|
+
export const DISABLED_DENIAL_KIND: DenialKind = "disabled";
|
|
518
|
+
|
|
490
519
|
/**
|
|
491
520
|
* One denial recorded by the preToolUse hook. `token` is the call's identity in
|
|
492
521
|
* the same space as grantToken() (base64 of `toolName \n salientArg`), used to
|